diff --git a/campaign/board.md b/campaign/board.md index 46495ad..1edc45b 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -49,4 +49,4 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | determinism oracle | verify | verified | high | 100% | 2026-09-07 | BYTE-IDENTICAL across 5 runs incl. cross-process: (Autosave).sav 978041ac…, (Autosave EndTurn).sav bb4fd9ac…; gzip MTIME=0; only loaded-post-turn re-save differs (Player.Status 4->0, Summary.Checksum). findings/subsystems/determinism-oracle.md | | engine: shim trace/compare emitter | engine | verified | high | 100% | 2026-09-07 | merged (sots-engine 9e110b4): emitter byte-exact vs mkfixture, tracer, snapshot/diff, Hook; ctest 18/18; tracecmp exits 0/1/2 as specified; shim links | | engine parity: first-wins keys + tokenizer rules | engine | in-progress | — | 0% | 2026-09-07 | fix mars/text + mars/parse + Python oracle to confirmed Mars::Script/GlobalConsts semantics; re-verify 100% | -| save-format tag corrections | verify | in-progress | — | 0% | 2026-09-07 | otnF/nextid/FAIDes/DHide/DWep/DName/ords/wpts into SAVE_FORMAT + save_reader; strict must stay 3/3 | +| save-format tag corrections | verify | verified | high | 100% | 2026-09-07 | byte-confirmed at offsets (otnF x62, nextid, FAIDes/DHide/DWep/DName x43, ords, wpts, paths); reader A()-matches them; 36 tests; strict 3/3, infos 259->197; SAVE_FORMAT §10 changelog | diff --git a/campaign/open-questions.md b/campaign/open-questions.md index b6c146b..f177221 100644 --- a/campaign/open-questions.md +++ b/campaign/open-questions.md @@ -37,3 +37,4 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re- - **RESOLVED: End-Turn oracle** — deterministic and cross-process: compare autosaves by sha256, no masking. Only when diffing a re-save of a *loaded post-turn* autosave: mask `Player.Status` (→0) and recompute/ignore `Summary.Checksum` (additive). (from [[determinism-oracle]]) - **SAVE_FORMAT tag corrections (fix Python reader + spec)** — real on-disk tags: `otnF` (not `ontF`) in Odes/Owep/Otch, `nextid` (not `nextId`) in NdGr2, Design = `FAIDes/DHide/DWep/DName`; `ords`/`wpts` are real tags. Python's positional R() matching hid these. RNG: float mapping `(float)(y*2^-32)`, `next_int` mask, and lazy-vs-eager twist at `left==0` still need binary confirmation. (from [[mars-stream]]) - **RESOLVED: tech `allows` default** — unlisted species = 1.0 (confirmed in `FUN_005822d0` tree-creation roll, strategic-turn-internals §2); `game/data` uses 100. Still open from game/data: what the engine's converter does with the 34 malformed tokens (`force_right o`, `crew false`, `1.0f`, `0-5`, ``90\``); repeated scalars in a block are last-wins per the sequential if/else consumers (loader-prototypes §M3) — confirm on a fixture in compare mode. (from [[game-data]]) +- **RESOLVED: SAVE_FORMAT tag names** — all corrections confirmed by bytes and applied to the Python reader + spec (§10). Residual doc debt: `findings/objects/save-editor-structs.md` and `verify/design-rules/SHIP_DESIGN_RULES.md` still quote R1's `ontF/faiDes/nextId` spellings (R1-provenance; annotate rather than rewrite). (from [[SAVE_FORMAT]]) diff --git a/verify/results/saves/turn2-strict-issues.txt b/verify/results/saves/turn2-strict-issues.txt index 76c7816..f9b5967 100644 --- a/verify/results/saves/turn2-strict-issues.txt +++ b/verify/results/saves/turn2-strict-issues.txt @@ -1,6 +1,8 @@ # save_reader --strict against real save turn2-state.sav (padding=joint, 0 resyncs, 0 hint-failures, 2503 raw bytes = opaque RNG blob) -# result: strict exit 0 -- 0 errors, 0 warnings, 259 info -# after findings/objects/schema-gaps-resolved.md §10 (reader: string values never ASCII-tested, len-0 strings; -# schema: NVO.indi unconditional, PrNSp iff PrMax>0, zdsc pairs, turnstats/tch int, Summary/CreateParams tags, CDT framed) -# same result on turn1-state.sav (257 info) and turn3-state.sav (259 info) -259x [info] tag '.' read positionally as (NULL-named items: Summary Settings/FxCrID, CreateParams MapP planets, Sim id lists, Vector3 bodies) +# result: strict exit 0 -- 0 errors, 0 warnings, 197 info +# after SAVE_FORMAT.md §10 tag-name corrections (from the C++ round-trip writer, re-verified in the bytes): +# otnF (not ontF) leads every odes/owep/otch element; nextid (not nextId) in NdGr2; +# Des header tags FAIDes DHide DWep DName; ords / wpts / paths are named framed arrays -> all A() now +# previous revision (schema-gaps-resolved.md §10): 259 info, of which 62 were "tag 'otnF' read positionally as 'ontF'" +# same result on turn1-state.sav (197 info) and turn3-state.sav (197 info; turn3 is the only save with an FPlan/wpts) +197x [info] tag '.' read positionally as (NULL-named items only: Summary Settings/FxCrID, CreateParams MapP planets, Sim id lists, Vector3 bodies) diff --git a/verify/save-reader/SAVE_FORMAT.md b/verify/save-reader/SAVE_FORMAT.md index 809a864..d2833b1 100644 --- a/verify/save-reader/SAVE_FORMAT.md +++ b/verify/save-reader/SAVE_FORMAT.md @@ -8,6 +8,9 @@ editors (R1 Bardez, R2 SOTSedit; `save-editor-structs.md`), the binary member ta `findings/objects/schema-gaps-resolved.md` (round 3), which settled every item that was marked VERIFY in the previous revision of this file. The synthetic fixture (`save_writer_stub.py`) round-trips under the same assumptions (`test_save_reader.py`). +The C++ typed writer (which reproduces the real saves byte-identically) later exposed a +handful of tag names this file had wrong or left unnamed; they are corrected throughout +and listed in §10. ## 1. Container @@ -102,7 +105,9 @@ NumLeg NumNotes NumPR NSprj Nexp NWeapXcl ndeflay rdtc numcreps ninv NumFlts Num NumMnF NVO NVE NVs NumPlgs2 PopNG mnsp nadct haltc PrNSp NTH nply ncls seno2 spc zdsc NumIDs PlayerIDs DesignIDs SystemIDs FleetIDs ShipIDs TradeIDs AllExc AllExcCF`. Count and element tags inside framed arrays (`Players`, `GOWinPly`, `sacq`, `slost`, `dipstats`, `preps`, -`odes`, `owep`, `otch`, `cme2`, `Ojvs`, `MapP` planets …) are always `"."` (§3). +`odes`, `owep`, `otch`, `cme2`, `Ojvs`, `ords`, `wpts`, `paths`, `MapP` planets …) are +always `"."` (§3). `ords` (BuildQueue), `wpts` (FlightPlan) and `paths` (NdGr2) are real +framed-array tags, not positional items (§10). Uncounted lists: `hist` holds n × `stats` frames until its END; `Sim` holds 7 × (`ISsp` string, `ISsu` float) after the players; the root holds one `CD` frame per custom-data id. @@ -116,9 +121,9 @@ string, `ISsu` float) after the players; the root holds one `CD` frame per custo | system-level `hindi` true | `indi` frame (IndependenceInfo) after `NVs` | | `NVO` entry | `indi` frame is **always** present after `isind` (inline member of the map value; `isind` does not gate it) | | `PrMax > 0` | `PrNSp` + n × (`PrSp`, `PrNum`); with `PrMax == 0` the `PrisH` frame ends right after `PrMax` | -| `HFPlan` true | `FPlan` frame | +| `HFPlan` true | `FPlan` frame: `wpts{"." n, n×"."{Wpt Tp nrt{nrp nrf nrt}}} FPsp2 FPeta2 FPogn2 FPdpos pnd` (turn3 only) | | `HLay` true | `Lay` frame (opaque) | -| `hbq` true | `BQ2` frame; `hsp` true → `pop`, `ppop` Population frames | +| `hbq` true | `BQ2` frame (same body as `BQ`: `ords{"." n, n×"."{desID con conleft sav ordID}}`); `hsp` true → `pop`, `ppop` Population frames | | `HasAIR` true | `AIR` frame (opaque) | | system `PID` non-null | `BQ` frame present (reader: optional by name) | | `SvSctOb` pointer non-NULL | `SvSctOb` frame before `zdsc` (present in the three real saves; `Opt`) | @@ -155,6 +160,14 @@ IsFxAv FxAv Tag Pwd(string) Team Settings{4×"." int}`. `stats` (PlayerTurnStats): `pop`(int64) `sacq{}` `slost{}` (VectorHelper: `set ses seop senp seno2 n×seot2`) `trn almem inc tdinc sav col bat tch`(int) `ncls` 3 × (`cls shpt shpl shpk satt satl satk`). +`Des` (design header, under `NumDes`/`NumLeg` n × (`DesID`, `Des{}`)): `FAIDes`(bool) `DHide`(bool) +`DWep`(int) `DName`(string), then the `DSec` section frames (kept generic). +Objective records (Player `odes`/`owep`/`otch`, each a framed VectorHelper of `"."` elements): +`odes` = `otnF otnL odid opid`; `owep` = `otnF otnL odet owep`(string) `owith`; `otch` = `otnF otnL odet +otch`(string) `owith`. +`NdGr2` (node grid): `paths{"." n, n×"."{npt npid npfr npto npctm npcby npdtn npdtf npenp npuse nptf}}` +then `nextid`(int). +`BQ` (system build queue): `ords{"." n, n×"."{desID con conleft sav ordID}}` — count 0 in turn1..3. Everything not covered by a shape (TechTree body, Events, ShipRecs, spy2, civr, comms, Ojvs, Attrib, sprjs, SvSctOb, trdmgr, spymgr, CD, …) is kept as the generic @@ -182,6 +195,9 @@ applied over R1/R2: * `Team` appears twice in Player: an int, later a nested `{ALid AL NA CF}` frame (typed key `Alliances`) * `GOWinPly` is a framed `VectorHelper` (`"."` count + n × `"."` int), empty in the real saves * `CreateParams`/`Summary` tag case is as written above (R1's camel-case names were wrong) +* Objective lead tag `otnF`, node-grid `nextid`, design header `FAIDes DHide DWep DName`, and the + framed arrays `ords`/`wpts`/`paths` are `A()` (byte-confirmed, §10); the reader keeps R1's + keys `faiDes dHide dWep dName` in its typed output for `stock_designs.py` ## 8. Reader output conventions @@ -207,3 +223,34 @@ applied over R1/R2: 5. `--padding auto` picks joint; `resyncs == 0` and `hint_failures == 0` on turn1/2/3; the only raw bytes are the `RNG` blob. Remaining unknowns are the bodies of the opaque frames listed in §6 (kept generic, not a parsing gap). +6. Every tag the reader matches by name is spelled as on disk (§10); the only positional + (`R()`) items left are NULL-named `"."` items and R1-named fields inside `crep`, + `NdGr2.paths` elements, `MapP` and `Waypoint.nrt`. + +## 10. Tag-name corrections (changelog) + +Found by the C++ typed save writer, which has to emit the real tag strings to round-trip +the saves byte-identically. The Python reader had matched these fields **positionally** +(`R()`), so a wrong spelling in the spec never failed a parse — the walker reported each as +an *info* ("tag 'otnF' read positionally as 'ontF'") and the typed output silently used the +wrong name. Each entry below was re-verified by inflating the saves and searching the +byte stream for `[int32 len][ascii]` (offsets are inflated offsets, decimal). + +| was (spec / R1) | is (on disk) | where | evidence | +|---|---|---|---| +| `ontF` | **`otnF`** | first field of every `odes`/`owep`/`otch` element | `otnF` ×62 in turn2 (first @72288, in `otch`; @123344 region `odes` element `otnF otnL odid opid`); ×60 turn1, ×62 turn3. `ontF` ×0 in all three | +| `nextId` | **`nextid`** | last item of `NdGr2` | `nextid` ×1: turn1 @564432, turn2 @575500, turn3 @580152 (value 44 in turn2, after `paths`). `nextId` ×0 | +| `faiDes dHide dWep dName` | **`FAIDes DHide DWep DName`** | first four items of every `Des` frame | ×43 each in turn2 (first Des frame @67820: `FAIDes` @67832, `DHide` @67844, `DWep` @67856, `DName` @67868); ×41 turn1, ×43 turn3. Camel-case forms ×0 | +| unnamed/positional | **`ords`** | framed VectorHelper inside `BQ` | ×3 in each save (turn2 @531144, 548092, 550044; each `BQ` @−12); count 0 in all | +| unnamed/positional | **`wpts`** | framed VectorHelper inside `FPlan` | turn3 only (the only save with `HFPlan` = 1): `FPlan` @591476, `wpts` @591492, element `Wpt` @591528 `Tp` @591540 `nrt` @591552. Absent from turn1/turn2 because no fleet has a flight plan there (`HFPlan` = 0 ×6 / ×7) | +| positional | **`paths`** | framed VectorHelper inside `NdGr2` | ×1 per save (turn2 @568072, `NdGr2` @568056); spelled as R1 had it but now matched by name | + +Reader changes (`save_reader.py`): the fields above are `A()`; `ords`/`wpts`/`paths` lost +the `flex` inline hedge (the game always frames them); the never-occurring `dName`/`faiDes`/ +`dHide` entries were dropped from `MANUAL_KINDS`. Typed-output keys are unchanged +(`faiDes dHide dWep dName` via `key=`; `otnF`, `nextid`, `ords`, `wpts`, `paths` were never +consumed under the old names). Effect on the real saves: strict still exits 0 with 0 +resyncs / 0 hint-failures; the info count drops from 257/259/259 to 197 on turn1/2/3 — the +60/62/62 `otnF` "read positionally" notes are gone, and every remaining info is a `"."` +item. `test_save_reader.py::TagNameCorrectionsTest` pins each correction and fails a +frame that uses the old spelling. diff --git a/verify/save-reader/save_reader.py b/verify/save-reader/save_reader.py index a55ae11..dcee455 100644 --- a/verify/save-reader/save_reader.py +++ b/verify/save-reader/save_reader.py @@ -400,7 +400,7 @@ MoraleEvent = Shape(None, [ BuildOrder = Shape(None, [ A("desID", "int"), A("con", "int"), A("conleft", "int"), A("sav", "int"), A("ordID", "int"), Rest(), ]) -BuildQueue = Shape("BQ", [R("ords", CArr(BuildOrder), flex=True), Rest()]) +BuildQueue = Shape("BQ", [A("ords", CArr(BuildOrder)), Rest()]) # `ords` = framed VectorHelper (§10) IndependenceInfo = Shape("indi", [ A("indsp", "int"), A("indcl", PlayerColor), A("indnm", "string"), A("indav", "string"), A("indba", "string"), Rest(), @@ -479,12 +479,19 @@ Prep = Shape(None, [ A("ncol", "int"), A("mpwr", "int"), A("mcls", "int"), A("mmsl", "int"), A("nshp", "int"), A("nsat", "int"), Rest(), ]) -Odes = Shape(None, [R("ontF", "int"), R("otnL", "int"), R("odid", "int"), R("opid", "int"), Rest()]) -Owep = Shape(None, [R("ontF", "int"), R("otnL", "int"), R("odet", "int"), R("owep", "string"), R("owith", "int"), Rest()]) -Otch = Shape(None, [R("ontF", "int"), R("otnL", "int"), R("odet", "int"), R("otch", "string"), R("owith", "int"), Rest()]) +# Objective records (elements of the framed odes/owep/otch arrays). The lead tag +# is `otnF` on disk (R1 had `ontF`; positional matching hid the typo -- caught by +# the C++ round-trip writer and confirmed in the bytes, SAVE_FORMAT.md §10). +Odes = Shape(None, [A("otnF", "int"), A("otnL", "int"), A("odid", "int"), A("opid", "int"), Rest()]) +Owep = Shape(None, [A("otnF", "int"), A("otnL", "int"), A("odet", "int"), A("owep", "string"), A("owith", "int"), Rest()]) +Otch = Shape(None, [A("otnF", "int"), A("otnL", "int"), A("odet", "int"), A("otch", "string"), A("owith", "int"), Rest()]) Note = Shape("Nts", [A("NtSys", "int"), A("NtTxt", "string"), A("NtTrn", "int"), Rest()]) +# Design header: on-disk tags are FAIDes/DHide/DWep/DName (R1's camel-case names +# were wrong). The typed-dict keys keep R1's spelling because +# verify/design-rules/stock_designs.py consumes them. Design = Shape("Des", [ - R("faiDes", "bool"), R("dHide", "bool"), R("dWep", "int"), R("dName", "string"), Rest("sections"), + A("FAIDes", "bool", key="faiDes"), A("DHide", "bool", key="dHide"), + A("DWep", "int", key="dWep"), A("DName", "string", key="dName"), Rest("sections"), ]) ConMods = Shape("ConMods", [ A("ConMod", "float", key="ConMod0"), A("SavMod", "float", key="SavMod0"), @@ -546,7 +553,7 @@ Player = Shape("Player", [ NodeRoute = Shape("nrt", [A("nrp", "int"), A("nrf", "int"), A("nrt", "int"), Rest()]) Waypoint = Shape(None, [A("Wpt", "int"), A("Tp", "int"), R("nrt", NodeRoute, flex=True), Rest()]) FlightPlan = Shape("FPlan", [ - R("wpts", CArr(Waypoint), flex=True), A("FPsp2", "float"), A("FPeta2", "int"), + A("wpts", CArr(Waypoint)), A("FPsp2", "float"), A("FPeta2", "int"), # `wpts` = framed VectorHelper (§10) A("FPogn2", "vec3"), A("FPdpos", "vec3"), A("pnd", "int"), Rest(), ]) PrisonerHold = Shape("PrisH", [ # PrisonerHold::Write @0x0056ec00 @@ -597,7 +604,9 @@ NodePath = Shape(None, [ R("npcby", "int"), R("npdtn", "int"), R("npdtf", "int"), R("npenp", "int"), R("npuse", "int"), R("nptf", "int"), Rest(), ]) -NodeGrid = Shape("NdGr2", [R("paths", CArr(NodePath), flex=True), R("nextId", "int"), Rest()]) +# NdGr2: `paths` (framed VectorHelper) then `nextid` -- all lower-case on disk +# (R1 had `nextId`; hidden by positional matching, see SAVE_FORMAT.md §10). +NodeGrid = Shape("NdGr2", [A("paths", CArr(NodePath)), A("nextid", "int"), Rest()]) # -- turn statistics (schema-gaps-resolved.md §6) -------------------------------- SystemEvent = Shape(None, [ # SystemEvent::Write @0x008189a0, element tag "." @@ -664,8 +673,9 @@ ROOT = Seq([ MANUAL_KINDS = { # tech tree / designs (R1 §7) "TNm": "string", "tfc": "bool", "tResCost": "int", "tResDone": "int", "tAcq": "int", - "tiAcq": "int", "tUnlck": "int", "dName": "string", "wfn": "string", "bId": "bool", - "faiDes": "bool", "dHide": "bool", + # (R1's `dName`/`faiDes`/`dHide` never occur on disk; the real Design tags + # FAIDes/DHide/DWep/DName are registered from the Design shape) + "tiAcq": "int", "tUnlck": "int", "wfn": "string", "bId": "bool", # objectives / comms / research / encounters (R1 §5, §6, §11) "cmp": "bool", "dsc": "string", "xcsn": "string", "nm": "string", "ntg": "string", "wep": "string", "gmch": "int", "drad": "float", "cst": "float", diff --git a/verify/save-reader/test_save_reader.py b/verify/save-reader/test_save_reader.py index d1d947a..1b6c144 100644 --- a/verify/save-reader/test_save_reader.py +++ b/verify/save-reader/test_save_reader.py @@ -409,6 +409,170 @@ class SchemaRoundTripTest(unittest.TestCase): sr.read_bytes(w.bytes(), padding="joint", strict=True, schema=sr.Seq([sr.R("p", sr.PrisonerHold)])) +class TagNameCorrectionsTest(unittest.TestCase): + """SAVE_FORMAT.md §10: tags the C++ round-trip writer had to emit to reproduce + the real saves byte-for-byte, which the spec/reader had wrong or unnamed. + Positional R() matching used to hide the misspellings; the fields are now + A() and a wrong spelling is a strict failure.""" + + REAL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "results", "saves") + + @staticmethod + def field_names(shape): + return [(f.name, f.auth) for f in shape.fields if isinstance(f, sr.Field)] + + def test_schema_declares_the_real_tags(self): + for shape in (sr.Odes, sr.Owep, sr.Otch): + names = self.field_names(shape) + self.assertEqual(names[0], ("otnF", True)) # not R1's "ontF" + self.assertTrue(all(auth for _, auth in names), names) + self.assertEqual(self.field_names(sr.Design), + [("FAIDes", True), ("DHide", True), ("DWep", True), ("DName", True)]) + # typed-dict keys keep R1's spelling (verify/design-rules/stock_designs.py reads them) + self.assertEqual([f.key for f in sr.Design.fields if isinstance(f, sr.Field)], + ["faiDes", "dHide", "dWep", "dName"]) + self.assertEqual(self.field_names(sr.NodeGrid), [("paths", True), ("nextid", True)]) + self.assertEqual(self.field_names(sr.BuildQueue), [("ords", True)]) + self.assertEqual(self.field_names(sr.FlightPlan)[0], ("wpts", True)) + for f in (sr.BuildQueue.fields[0], sr.FlightPlan.fields[0], sr.NodeGrid.fields[0]): + self.assertFalse(f.flex, f) # framed VectorHelper, no inline hedge + + def test_catalog_knows_only_the_real_spellings(self): + for wrong in ("ontF", "nextId", "faiDes", "dHide", "dWep", "dName"): + self.assertNotIn(wrong, sr.GLOBAL_KINDS) + self.assertEqual(sr.GLOBAL_KINDS["otnF"], "int") + self.assertEqual(sr.GLOBAL_KINDS["nextid"], "int") + self.assertEqual((sr.GLOBAL_KINDS["FAIDes"], sr.GLOBAL_KINDS["DHide"], + sr.GLOBAL_KINDS["DWep"], sr.GLOBAL_KINDS["DName"]), + ("bool", "bool", "int", "string")) + for name in ("ords", "wpts", "paths"): + self.assertIsInstance(sr.GLOBAL_SHAPES[name], sr.CArr) + + @staticmethod + def objectives(lead): + """Player-level odes/owep/otch arrays as the real saves lay them out.""" + w = sw.SaveWriter() + w.begin("odes"); w.int(".", 1) + w.begin("."); w.int(lead, 2); w.int("otnL", 2); w.int("odid", 18); w.int("opid", 32); w.end() + w.end() + w.begin("owep"); w.int(".", 0); w.end() + w.begin("otch"); w.int(".", 1) + w.begin("."); w.int(lead, 1); w.int("otnL", 1); w.int("odet", 0) + w.string("otch", "WEP_RedLas"); w.int("owith", 1); w.end() + w.end() + return w.bytes() + + def test_objective_records_use_otnF(self): + schema = sr.Seq([sr.A("odes", sr.CArr(sr.Odes)), sr.A("owep", sr.CArr(sr.Owep)), + sr.A("otch", sr.CArr(sr.Otch))]) + res = sr.read_bytes(self.objectives("otnF"), padding="joint", strict=True, schema=schema) + self.assertEqual(norm(res.typed["odes"]), [{"otnF": 2, "otnL": 2, "odid": 18, "opid": 32}]) + self.assertEqual(res.typed["owep"], []) + self.assertEqual(norm(res.typed["otch"]), + [{"otnF": 1, "otnL": 1, "odet": 0, "otch": "WEP_RedLas", "owith": 1}]) + self.assertFalse([i for i in res.issues if "read positionally" in i.msg]) + # R1's misspelling is no longer accepted silently + with self.assertRaises(sr.SaveFormatError): + sr.read_bytes(self.objectives("ontF"), padding="joint", strict=True, schema=schema) + + @staticmethod + def design(tags): + w = sw.SaveWriter() + w.begin("Des") + w.bool(tags[0], True); w.bool(tags[1], False); w.int(tags[2], 1); w.string(tags[3], "Honor Lance") + w.begin("DSec"); w.int("ga", 1); w.end() # sections stay generic + w.end() + return w.bytes() + + def test_design_header_tags(self): + schema = sr.Seq([sr.A("Des", sr.Design)]) + data = self.design(("FAIDes", "DHide", "DWep", "DName")) + wk, root = walk(data) + self.assertTrue(all(c.hinted for c in root.children[0].children[:4])) # typed from the Des shape + res = sr.read_bytes(data, padding="joint", strict=True, schema=schema) + d = res.typed["Des"] + self.assertEqual((d["faiDes"], d["dHide"], d["dWep"], d["dName"]), (True, False, 1, "Honor Lance")) + self.assertEqual([s["_name"] for s in d["sections"]], ["DSec"]) + self.assertNotIn("FAIDes", d) # key stays R1's; only the tag changed + with self.assertRaises(sr.SaveFormatError): # camel-case R1 spelling is not on disk + sr.read_bytes(self.design(("faiDes", "dHide", "dWep", "dName")), + padding="joint", strict=True, schema=schema) + + @staticmethod + def node_grid(tag): + w = sw.SaveWriter() + w.begin("NdGr2") + w.begin("paths"); w.int(".", 1) + w.begin(".") + for i, n in enumerate(("npt", "npid", "npfr", "npto", "npctm", "npcby", + "npdtn", "npdtf", "npenp", "npuse", "nptf")): + w.int(n, i) + w.end() + w.end() + w.int(tag, 44) + w.end() + return w.bytes() + + def test_node_grid_nextid(self): + schema = sr.Seq([sr.A("NdGr2", sr.NodeGrid)]) + res = sr.read_bytes(self.node_grid("nextid"), padding="joint", strict=True, schema=schema) + g = res.typed["NdGr2"] + self.assertEqual(g["nextid"], 44) + self.assertNotIn("nextId", g) + self.assertEqual((g["paths"][0]["npt"], g["paths"][0]["nptf"]), (0, 10)) + with self.assertRaises(sr.SaveFormatError): + sr.read_bytes(self.node_grid("nextId"), padding="joint", strict=True, schema=schema) + + def test_build_queue_ords_and_flight_plan_wpts(self): + w = sw.SaveWriter() + w.begin("BQ"); w.begin("ords"); w.int(".", 1) + w.begin("."); w.int("desID", 5); w.int("con", 1); w.int("conleft", 2); w.int("sav", 3); w.int("ordID", 7); w.end() + w.end(); w.end() + w.begin("FPlan"); w.begin("wpts"); w.int(".", 1) + w.begin("."); w.int("Wpt", 272); w.int("Tp", 1) + w.begin("nrt"); w.int("nrp", -1); w.int("nrf", 0); w.int("nrt", 0); w.end() + w.end(); w.end() + w.float("FPsp2", 2.5); w.int("FPeta2", 3) + w.vec3("FPogn2", 1.0, 2.0, 3.0, named=True); w.vec3("FPdpos", 4.0, 5.0, 6.0, named=True) + w.int("pnd", 0) + w.end() + schema = sr.Seq([sr.A("BQ", sr.BuildQueue), sr.A("FPlan", sr.FlightPlan)]) + res = sr.read_bytes(w.bytes(), padding="joint", strict=True, schema=schema) + self.assertEqual(norm(res.typed["BQ"]["ords"]), + [{"desID": 5, "con": 1, "conleft": 2, "sav": 3, "ordID": 7}]) + fp = res.typed["FPlan"] + self.assertEqual((fp["wpts"][0]["Wpt"], fp["wpts"][0]["Tp"], fp["wpts"][0]["nrt"]["nrp"]), (272, 1, -1)) + self.assertEqual((fp["FPsp2"], fp["FPeta2"], fp["FPogn2"], fp["pnd"]), (2.5, 3, [1.0, 2.0, 3.0], 0)) + # an empty queue is a framed count of 0 (what turn1..3 carry) + w = sw.SaveWriter() + w.begin("BQ"); w.begin("ords"); w.int(".", 0); w.end(); w.end() + res = sr.read_bytes(w.bytes(), padding="joint", strict=True, schema=sr.Seq([sr.A("BQ", sr.BuildQueue)])) + self.assertEqual(res.typed["BQ"]["ords"], []) + # the inline (unframed) hedge the old flex R() allowed is not what the game writes + w = sw.SaveWriter() + w.begin("BQ"); w.int("ords", 0); w.end() + with self.assertRaises(sr.SaveFormatError): + sr.read_bytes(w.bytes(), padding="joint", strict=True, schema=sr.Seq([sr.A("BQ", sr.BuildQueue)])) + + @unittest.skipUnless(os.path.exists(os.path.join(REAL, "turn3-state.sav")), "real saves not present") + def test_real_saves_carry_the_corrected_tags(self): + res = sr.read_save(os.path.join(self.REAL, "turn2-state.sav"), padding="joint", strict=True) + players = [p["Player"] for p in res.typed["sim"]["players"]] + first = players[0]["designs"][0]["Des"] + self.assertEqual((first["dName"], first["faiDes"], first["dHide"], first["dWep"]), ("Armor", False, False, 0)) + self.assertEqual((players[0]["otch"][0]["otnF"], players[0]["otch"][0]["otch"]), (1, "WEP_RedLas")) + self.assertTrue(any(o["odid"] == 18 and o["opid"] == 32 for p in players for o in p["odes"])) + self.assertEqual(res.typed["sim"]["NdGr2"]["nextid"], 44) + self.assertEqual(len(res.typed["sim"]["NdGr2"]["paths"]), 43) + # every remaining positional match is a NULL-named "." item + self.assertFalse([i for i in res.issues if "read positionally" in i.msg and "tag '.'" not in i.msg]) + # turn3 is the only save with an active flight plan + res3 = sr.read_save(os.path.join(self.REAL, "turn3-state.sav"), padding="joint", strict=True) + plans = [f["Flt"]["FPlan"] for f in res3.typed["sim"]["fleets"] if f["Flt"]["HFPlan"]] + self.assertEqual(len(plans), 1) + self.assertEqual((plans[0]["wpts"][0]["Wpt"], plans[0]["wpts"][0]["nrt"]["nrp"]), (272, -1)) + + class CliTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory()