File size: 10,430 Bytes
543c8e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# Converter behavior — F26–F28

Evidence: the two hashes and method RVAs in [MB3 DAT](MB3_DAT.md), decompiled IL/C# under `assets/analysis/converter/<build>/`, and [E02](experiments/converter-2026-10-21.md). Runtime results here are from original unmodified binaries under Mono 6.14.1 on ARM64 macOS; no native editor or Windows run is implied.

## F26 — Dispatch, normalization and text encoding

The CLI accepts exactly four or five arguments:

```text
mono converter.exe srcFormat srcFile dstFormat dstFile [logFile]
```

Case-sensitive source tokens: `mb3dat`, `mb3txt`, `hl1map`, `hl2vmf`, `q3map`. Destination adds `wfobj`. Paths are passed directly to file APIs; extensions do not select the codec. Every accepted input type can reach every destination branch, including same-format conversions. Usage with zero arguments exits 1, recognized operations return 0 and print `Done.`, caught failures exit 2. The main entrypoint is RVA `0x2050` in both binaries.

Latest call sequence is load → `World.Optimize` → save. There is no geometry evaluation between load and save. The 2015 binary has no optimization method or property arrays.

`Optimize` (latest RVA `0x365c`) counts non-null values per plane for each string/UV property independently, sorts properties by descending usage, retains original table order for ties, drops unused names, remaps each plane's slots, and nulls empty property arrays. Empty strings count as used. The fixture changes `[unused, rare, common]` to `[common, rare]` because common occurs twice and rare once. This means DAT→DAT and TXT→TXT are not general byte-preserving identity operations. The 255/256-property probes retain original order for equal usage. It does not merge brushes, clip planes, discard invalid geometry, synthesize missing computed data or sort objects. Mixed brush/patch TXT order is grouped into separate arrays and saved brushes first.

Text input goes through `Common.ReadAllText` (latest RVA `0x277c`, old `0x2738`): it checks the first byte, selecting UTF-8 for `ef`, rejecting initial `00`, `fe`, `ff` as possible unsupported UTF-16/32, and otherwise supplying ISO-8859-1 to `File.ReadAllText`. Runtime/BOM detection still participates in that API; this is not a complete general encoding detector. Output world/map/OBJ writers use default `StreamWriter`; measured Mono output is UTF-8 without BOM and LF newlines. Windows newline/float formatting has not been measured.

This produces a concrete mismatch: BOM-less UTF-8 `é` is read as `é`; a further TXT round trip produces `é`. UTF-8 with BOM is read correctly, but DAT's ASCII writer reduces `é` to `?`. `utf8-no-bom-mojibake`, `utf8-txt-repeat` and `utf8-bom-ascii-loss` retain the exact bytes. The replacement should detect/declare encodings and report DAT character loss.

Latest TXT semantics, beyond its grammar:

- Plane triples map directly to P0/U/V. Parsing floats uses invariant culture and binary32; writing uses `float.ToString(invariantCulture)` without an explicit round-trip format. Extreme numeric formatting remains unmeasured on Windows versus Mono.
- Duplicate property names in the same table, duplicate plane slots, and out-of-range TXT property indices throw explicit application errors. String and UV tables are separate namespaces.
- With computed features true, the loader assumes a plane polygon and patch points/normals exist, although the grammar permits omission. Missing polygon produced `IndexOutOfRangeException`, exit 2. It does not generate the data.
- With computed features false, optional computed lists are ignored; the emitted world has none. The corresponding probe succeeds.
- Patch vector-list lengths, odd/minimum anchor dimensions and minimum tessellation are not validated by the plain data constructors. A successful CLI conversion is not a valid-editor-document guarantee.
- Escaping (`TextUtils.Escaped` RVA `0x3300`, `Unescaped` `0x3460`) handles CR, LF, tab, single/double quotes and backslash. The unescaper's default escape branch inserts a backslash, rather than implementing Unicode/hex/C-style general escapes. Exhaustive lexical edge cases remain open.

## F27 — Geometry transformations and losses

Import helpers: latest HL1 `processSolid` RVA `0x6728`, VMF `0x68fc`, Q3 brush `0x6c74`, Q3 patch `0x6dd8`. Export RVAs: HL1 `0x5354`, VMF `0x5688`, Q3 `0x59ac`, OBJ `0x5d8c`. The old counterparts are indexed in `static.json`.

For all three MAP/VMF brush routes, external `(x,y,z)` becomes internal `(x,z,-y)`. Given external plane points `a,b,c`, the imported native plane is:

```text
P0 = transform(a)
U  = transform(c) - P0
V  = transform(b) - P0
```

Export emits inverse-transformed `P0`, `P0+V`, `P0+U`, in that order. The asymmetric `[1,2,3] / [4,5,6] / [7,8,9]` plane returns identically after each of the three export/import routes under both binaries. This proves the point/basis order on those fixtures; editor half-space and numerical tolerance contracts remain separate.

| Route | Established conversion behavior |
| --- | --- |
| HL1 MAP import | Recurses entity solids, collecting geometry into one world. Entity fields, texture names and all texture-axis/scale/rotation data are discarded. Computed false, no properties or patches. |
| HL2 VMF import | Collects solids through world/entity/hidden-solids/hidden-entities nodes. Looks for literal `"plane"` key, parses its point string by manual delimiters, takes the first matching plane in each side; ignores materials, IDs, displacement/extra data and entity semantics. Hidden geometry is flattened without a hidden field. |
| Q3 MAP import | Collects brush planes and `patchDef2`. Discards entity properties, material/shader names, flags and texture UV data. Patch XYZ gets the same coordinate transform, anchors flatten U-fastest, tessellation is hardcoded **4×4 per tile**, computed false. |
| HL1 MAP export | One worldspawn with `mapversion=220`; every plane gets `CLIP`. Dominant absolute normal axis chooses one of three fixed bracketed UV-axis pairs; suffix is `0 1 1`. Patches are omitted. |
| VMF export | One worldspawn, solid/side blocks, `TOOLS/TOOLSNODRAW`, dominant-normal fixed axes and scale `0.25`. No original IDs/entities/materials/displacements survive. Patches omitted. |
| Q3 MAP export | One worldspawn; brush sides use `common/caulk 0 0 0 0.5 0.5 0 4 0`. Patches use `radiant/notex`; dimensions/XYZ anchors preserved but UVs replaced by integer `(u,v)` indices. Tessellation density is not encoded. |
| OBJ export | Requires world computed true even for an empty world. Emits internal coordinates directly, with no MAP axis transform; no normals, UVs, materials, object/group identifiers or triangulation. Brush contours become polygon faces; patch points become quad-grid faces. |

OBJ brushes deduplicate position vectors using component-wise f32 equality (no epsilon) within each brush using `List<vec3>.Contains`, preserve first encountered order, then clear the local list between brushes while advancing the global 1-based index. Patch points are emitted without deduplication. For U stride `s`, each patch quad has indices `(i, i+s, i+s+1, i+1)`; brush contours retain input order. Stored normals are not written. Runtime probes produce one triangle from an explicit contour and four quads from a 3×3 point grid. Convex clipping, patch tessellation and triangulation must be supplied by a separate geometry implementation.

`patch-q3-roundtrip` demonstrates a loss even when anchor positions survive: 2×2 tessellation becomes 4×4. `patch-hl1-dropped` demonstrates successful output containing no patch. The converter emits no loss warning for these cases, or for the replacement materials/entity flattening. Native conversion needs explicit reports under P6.13, not success-only equivalence tests.

## F28 — Failure and filesystem contract

- Invalid operation/argument validation happens **before** enabling the optional log flag. `MB3TXT` exits 2 with its error on stdout, leaving an existing log unchanged even when a log path was supplied.
- Valid-mode failures with a log path overwrite that log (ISO-8859-1 via `Common.WriteAllText`), without appending; messages include exception type/message and inner exceptions, separated with explicit CRLF. Without a log, errors use stdout/console color. Stderr is empty for all 101 captured probes.
- A successful operation does not clear, truncate or create the log. Every probe seeds `STALE LOG\n`: it remains unchanged on success. This explains why shipped BAT wrappers delete old logs first.
- Missing input and invalid syntax exit 2. DAT wrong/other version exits 2. No source/output suffix check is made. All original input streams close before save. The empty DAT same-path probe succeeds; a preexisting destination is overwritten. These are narrow observations, not a general atomic-save guarantee.
- Output uses `File.Create` before most writing: no temp-file/rename transaction. Later exceptions can leave partial output. Filesystem permission errors, log-write failure, concurrent writes and interruption need their own probes.
- DAT readers ignore trailing bytes; DAT→DAT removes them. World reserved bits/selector-3 fixtures are accepted and normalized. A successful read does not mean exact input consumption.
- `World.read(count)` calls `Stream.Read` once and **ignores the returned count**, reusing a buffer. Short reads preserve previous bytes. `truncated-last-float` succeeds in both versions: the old output's V becomes `(7,9,9)` and the new output's V becomes `(7,8,0)` for the exact retained mutations. Latest `truncated-patch-count` also succeeds, reusing zero float bytes as the absent count. The bounded inspector rejects every prefix truncation of its one-plane fixture.
- Latest `readBytesTo0Terminator` casts `ReadByte()` to byte and loops until zero. EOF becomes `ff`; an unterminated string can grow its list indefinitely. This is **static evidence, deliberately not executed**. Counts, array products, property indices and dimension constraints also lack systematic validation. Do not use the legacy tool as an unbounded fuzz target.

The independent Rust codec should preserve meaningful valid data, report intentional compatibility deviations and losses, reject truncation/overflow and atomically save. Reproducing corruption or unbounded allocation is not a parity requirement. P6.19/P6.20 track the remaining validation and safety work.