Prerequisite: The DFile container format and STG — stage files & the UI.
When you pick Save on the control stage (CTL.STG), the game runs the
savegame("Titanic 1.0") builtin, which pops the native Save As dialog and
writes a .ti file. Load game runs opengame, which reads one back. The
example saves under gamefiles/<lang>/save/**/*.ti were produced by the shipped
TI.EXE.
Reference implementation:
engine/src/df/savegame.ts
(the byte format) and
engine/src/runtime/saveload.ts
(what the session puts in and takes out — see
Saving & loading at runtime, which also covers the
in-browser saved-games UI).
Dust writes the same file. DreamFactory 1's
.rtdsaves share this envelope byte for byte — the samefourCC, the sameODTRTRFD, the same position table and 64-byte alignment — so the reader and writer below take them unchanged. What differs is the offsets inside two containers and four record strides: see Saved games, DreamFactory 1 (.rtd).
A save file is the same container skeleton as every other game file — a 1024-byte header, a position table, then numbered containers — but two header bytes mark it as a save rather than a room:
| Offset | Value in a save | Value in a normal game file |
|---|---|---|
0 (fourCC) |
0x00010000 |
file-specific |
| 32 (signature) | ODTRTRFD |
LPPALPPA |
opengame checks both (and the "Titanic 1.0" version string, below) — the
error messages "This is not a valid saved game file." and "This saved game
is from a different version of this title." come from these checks.
Unlike the RAM-garbage padding inside the containers (see below), the file layout is deterministic, so a rewritten save reproduces every byte the loader reads (the output is not byte-identical — the port zeroes the ignored regions the original left junk in):
- container 0 always begins at offset 1536 (1024-byte header + a 512-byte, 128-entry position-table region);
- every later container is aligned to a 64-byte boundary after the previous container's data;
- the file size is rounded up to 64.
The bytes between the position table and 1536, and the padding between
containers, are leftover process memory (stale heap pointers) — the loader never
reads them. The port zero-fills them. Everything the loader actually reads
(header fields, position table, and each container's id/size/data)
round-trips exactly across all shipped saves.
The containers are a serialization of the engine's live C++ object graph.
Many records embed raw process pointers (0x7c91056d, 0x01d2…, a DFValue
vtable at 0x00431e0f) that mean nothing on reload — the loader rebuilds them.
They must still be treated as pointers rather than as magic numbers: see
Finding the grid.
Two consequences:
- The number of containers varies per save (24 in an early save, 21 in
another) but the order does not. The writer is a single routine
(
0x413910, called bysavegame's implementation at0x4137a0) and it emits its containers in one fixed sequence, below; the count varies by exactly (open files) + 3 × (open tracks) + (active walks carrying a waypoint payload). All 109 shipped saves match that map positionally, with no exceptions. - To read a save you skip the pointers and take the names, counts and values; to write one byte-compatibly you reproduce the meaningful bytes and may put anything (the port uses zeros) where the loader expects an ignored pointer.
The order below is the writer's own, read out of 0x413910 and checked
positionally against all 109 shipped saves; the roles are also confirmed by
diffing the same record across many saves.
| Container | Contents |
|---|---|
| 0 | manifest, built on the stack: "Titanic 1.0" version (Pascal string @0), disk family "Titanic1"/"Titanic2" (@+0x104), nine 256-byte path slots (@+0x1fc — the Save As / tour directories), the live CLUT (@+0xb0c, 256 × {i16 index, i16 rgb[3]} — the loader copies it into the palette global and applies it, 0x414aa8..0x414b07; the lower 128 entries are the open set's own palette table and a cross-room patch must replace them or the room comes back in the old room's colours), the open-file count (@+0x130c), then one 260-byte record per open file at +0x1310: the file's old heap handle (u32) followed by its path as a Pascal string (titanic2:data:cargo.set). The handle is not junk — it is the key every other container's file references resolve through, see how the loader re-opens the room |
| 1 | current location, a fixed 786 bytes from 0x489d40: the frame counter (@442 — see below), stage file (@520, "main.stg"), the open set file's old handle (@544 — resolved through the manifest, above), set base (@596), scene (@612), view (@628), the set's actor / main-scene register container refs (@644 / @652) and the scene register's record count (@656 — the loader's scene lookup walks exactly this many records; equal to the set's scene count in all 109 shipped saves) |
| 2 | the cast: n × 160-byte actor records — see The actor container |
| 3 | open casts: n × 28 (two pointers, a u32, the .cst filename as a Pascal string at +12). A load has to reopen these — the room's crowd is instanced from them and no openset runs to open them itself; see The crowd comes from this container |
| 4 | inventory — every loaded prop: 72 × 158 in every shipped save, inventory items first — see The inventory container |
| 5 | open shops (.shp): n × 28, the same shape as the casts |
| 6 | open tracks: n × 40-byte descriptors. A load has to reopen all of them, not just the one that was playing — see The track containers |
| 7 … 6+3n | three containers per open track, in descriptor order: the track's registered, playing and looping sound lists, 104 bytes per record. Counts come from the descriptor's +4/+6/+8 |
| globals | the script global variables — the core story progress (clock, phase, mission, playerdeath, every …phase/…count, the boiler pressures, the minigame state…) |
| globals + 1 | the globals' string pool: every string-valued variable's text, as [len][chars] entries. The loader reads the pair together (TI.EXE stores the pool handle at globals-blob +0x10) |
| +2 | the loops table, verbatim: 0x540 = 32 × 42 |
| +3 | the crickets table, verbatim: 0x4a0 = 16 × 74 |
| +4 | the walks table, verbatim: 0x6e0 = 16 × 110 |
| (var) | one waypoint payload container per active walk slot whose handle at +0x12 is non-null — see The scheduler containers |
A real one, end to end — the endgame save at the First Class Lounge. Because the order is the writer's own walk, every index here is computed and none is searched for: the count of open tracks in container 6 places the globals, and the globals place everything after them.
A save holds no pictures, no sound and no scripts. It is 46 KB of tables — and the biggest single block is the manifest, because the paths of every open file cost more bytes than the state of the game.
The three tables at the end are not a serialization of anything: 0x442530
sits directly in front of the master service pass 0x442550 and hands back
pointers to its own three tables (0x48bcd0 loops, 0x48b830 crickets,
0x48b150 walks), which the writer memcpys into the file. The save is the
live scheduler.
(There is no location-stream container — see the container that wasn't there.)
A room's crowd is not in the boot cast. gang.cst is opened once at boot and
holds the 25 named characters; the eight members the extras are instanced from —
life1 bruce1 jim1 jay1 brown1 paul1 ani1 molly1 — live in extra.cst, which
lounge1c.set, smoke.set and deckbd2.set each opencastfile from their own
openset.
A load runs no openset (#143),
so unless container 3's list is reopened the crowd records have no cast member to
be re-instanced from, and restoreActors drops every one of them. Measured across
the 109 shipped English saves:
saves listing extra.cst here |
47 of 109 |
| saves restoring a room with placed, visible characters that resolved to nothing | 39 of 109 |
| such records | 344 |
Worst case ENDGAME1/09 - Traded letter for Baby (deckbd2), 39 records. The three
affected sets are the most populated rooms of the endgame.
The 47 saves whose container 3 carries a second record carry extra.cst in
every one, and they are exactly the 47 with crowd records that resolve to
nothing without it. Reopening what the list names resolves all 344
(#186).
Two points:
- The list is the file's, not a guess from the set being entered. The save records what was open, which is precisely the question being asked.
- A cast file the previous room had open and the save does not name is left
open, not closed. It is inert —
resetCastputs every member down, and a member the file has no record for stays down.
restoreActors logs a drop rather than failing, so a missed reopen is silent.
The regression test asserts that every record with a set and visible, in every
shipped save, resolves to a live actor after the load.
The set base name at c1 @596 is written and restored, but it is not what the
loader opens. opengame's resume re-opens every file the container-0 manifest
names, then (0x41514a) takes the set file's old handle from c1 @544, finds
the manifest record whose first dword matches (0x4153f0 — a miss is its own
fatal, line 0x1127), and treats that record's path as the open set. It then
reads the set's scene register from the container refs restored at c1 @644 /
@652 and looks the saved scene and view names up in it (0x43a0b0) — walking
exactly c1 @656 records, a count that is restored verbatim and never
recomputed from the file it just read. A scene the walk doesn't reach raises
error 10 → "Fatal error at line 4248 (code 2)". Two ways to hit it: the
manifest still pathing another room's set, and a base set with fewer scenes than
the current one, whose smaller count hides the tail of the register (crew's 2
hides cargo's Scene4–6). The casts, shops and tracks resolve the same way: each
of their records leads with an old handle that is matched against the manifest
(0x4152e0 with the ODCC/ODDP/GNOS/ODCS type tags).
Measured across all 109 shipped saves: c1 @544 matches exactly one manifest
record, that record's path names a set file whose scene register contains the
save's scene (the name compare is case-insensitive — the registers store
Scene10, the saves scene10), and @644/@652 equal that file's own register
refs from its container-0 header.
Only the basename of that path is read. Between finding the record and
opening the file, the resolver hands the buffer to 0x42bc20, which strips
everything up to and including the LAST ":" — titanic2:data:cargo.set becomes
cargo.set — and 0x429e30 opens that, through the resource path table, in the
same call the engine uses for a script's own opensetfile("cargo.set"). So the
volume and directory a record carries are decoration: which disc the file comes
off is decided by the mounted CD (container 0 @256), not by the prefix. A patch
may therefore keep the base's prefix even when the story has crossed to the other
disc since — it names the wrong volume and nothing reads it.
The consequence for writing: a patch that changes the room must re-path the
manifest's set record and rewrite the register refs and the scene count
(SavePatch.setFile — the record's id is left alone so everything else still
resolves). In DosBox, a save carrying crew.set in the manifest and
cargo/scene4 in c1 makes TI.EXE restore the crew hallway and die at line 4248
looking for a scene it can never have; with the manifest fixed but crew's scene
count of 2 kept, it dies at the same line with cargo's Scene5 sitting unreachable
at register index 4. (A correlation sweep of every u16/u32 in c1's tail against
the sets' own facts — scene index, view index/ID/count, register record offsets,
viewport — matches nothing else across the 109; @656 is the only set-shape
field the blob restores.)
Container 1 is a verbatim dump of the 786 bytes at 0x489d40, and the loader
copies all 786 back — but not blindly. Before the copy (0x4142b2) it stashes
three 146-byte windows of the live block on the stack — [+0, +146),
[+146, +292), [+292, +438) — along with the dwords at +778 and +782, and
puts them all back afterwards (0x41431b..0x414365). So the range the file
actually gets to restore is exactly [438, 778), and the first thing in it
is the engine's displayed-frame counter, 0x489efa − 0x489d40 = @442.
(framerate is the dword after it, @446. It reads 3 in all 109 shipped saves:
the scripts that change it — the fencing stage, the turbine — put it back before
the player can reach the save menu.)
The counter is what frame() answers with (0x4273b0 fetches it; 0x439b80
bumps it once per pass, 20 a second — see
timing),
and restoring it is what makes an absolute frame stamp in a global mean
anything after a load. BOOTFILE's advancephase writes paintframe = frame()
when mission 2 opens and BINL.SET's cargo crate asks
frame() - paintframe > 10000, so a counter that runs from the session's start
rather than the saved game's declares the ten minutes over the instant the save
comes back — the painting gone, on a save taken with it still in the crate
(#221). Measured across the
corpus: the counter rises monotonically along each numbered series (disc 1:
64 → 32469 → … → 346349) and no frame stamp
in the globals ever exceeds its own save's.
The actor container is a grid of 160-byte records, and each one is the live
runtime struct dumped verbatim — so the frame is the whole difficulty. A record
does not begin at its name, unlike the prop grid, and TI.EXE says so: 0x410d00
is the routine that fetches a record by name, and it computes the record with a
stride of 160 (lea eax,[eax+eax*4]; shl eax,5), string-compares against
record+0x50, and then rep movsds all 160 bytes to the caller.
So the name is at +0x50: the five string fields are the record's second half
and every numeric field sits before them. Each accessor then reads its own field out
of that copy, which is how the rest is mapped — the buffer is at esp+8, or
esp+0x10 for actorxyz:
| Offset | Type | Field | Recovered from |
|---|---|---|---|
| +0 | i16 | actorvisible — >0 is on screen |
0x40eec0 |
| +2 / +6 | ptr | the CST file and the member's data handle (rebuilt; actorlock's unlock path 0x411ab0 locks +6 and re-reads container +14 from file +2) |
0x411ab0 |
| +10 / +14 | u16 | the member's logic / script container locations in its CST — logicLocation and logicLocation + 1, 3101 of 3101 named-member records |
the CST files |
| +18 | i16 | placed flag — 1 iff the record names a set (2258/2258 vs 0/1207): assigned a place at least once, and putdownactor hides without clearing it |
the corpus |
| +24 | i16 | actordeg, 0..255 |
0x40e850 |
| +26 / +28 / +30 | i16 | actorxyz 1/2/3 — the SET's own X, Z, Y order |
0x40f285/97/a9 |
| +32 | i16 | actorturn — degrees per service pass while turning. Exactly two values over the 3465 records: 16 (the engine's default at creation — every record naming no set, plus 51 placed ones) and 10 (stdturn, the other 2207 placed). Carried by the port since #191; a load that leaves it 0 makes every restored character turn ten times too slowly |
0x410937 |
| +34 | i16 | current step within the pose (the walk cycle's frame; < the count at +36 in 2959 of 3101) | the corpus |
| +36 | i16 | step count of the current pose, cached (stand = 1, the gang's walk = 20 — matches the CST pose tables) | the CST files |
| +38 | i16 | actorspeed |
0x40ead0 |
| +42 | i16 | actorscale — 1000 is neutral |
0x40ea40 |
| +46 | i16 | a creation counter — per save, member k of the cast reads base+k with the base growing monotonically over the session (save 1/01 reads 5,6,7…; late saves read 8125+). Engine bookkeeping, nothing to restore |
the corpus |
| +72 | i32 | actorvalue — conversations had |
0x410be0 |
| +76 | i16 | actorzclip |
0x410c70 |
| +78 | i16 | resource-lock flag (actorlock 0x411950/0x411ab0 pin/release the sprite data) |
0x41195a |
| +80 | pstr | actor (cast member) name | the grid |
| +96 | pstr | the set the actor is in ("deckbd", "control", "gym") |
|
| +112 | pstr | actorstar — the spot they were put on, or a walk sentinel |
|
| +128 | pstr | actorpose ("stand" / "walk" / "dead") |
|
| +144 | pstr | actorowner |
With those, the record's numeric half has no unexplained structure left: what is not a restorable field is resource bookkeeping the loader rebuilds (the pointers, the lock, the container locations — implicit in the member's name) or animation scratch the next service pass overwrites (step, step count, the counter). Nothing here changes what a load restores.
Every offset above is checked against all 3465 records of the 109 shipped saves:
actorvisible is only ever 0 or 1 and no visible record lacks a set; deg stays
inside 0..255; speed and zclip only ever hold values a script passes to those
commands. The decisive one is the position — for the 2122 records naming a star that
really is a star of the set they also name, the coordinates are that star's,
exactly, in 2105 (99.2%). The 17 that differ are Max mid-patrol on the boat deck
and one record parked on the walktostar sentinel, i.e. an actor genuinely not
standing on his star. No other framing of these bytes produces that.
actorscale at +42 is confirmed three ways: the accessor (0x40ea40 reads
[esp+0x32] of a buffer at esp+8, i.e. record+42), the value distribution (a
handful of round values per actor, 1000 neutral), and the per-character
clustering — about one scale per room, which is what stdscale being a table of
per-room constants predicts. It is the field that makes a restored character
drawable: a record put back with scale 0 is placed correctly and gates every
script correctly, and is never drawn. It also carries the two script overrides
stdscale(currentset()) cannot reproduce (gang.cst 1323's stoker at 9000,
extra.cst 0003's 2700), which is why the port reads it from the record and
writes it back rather than asking the cast — see
Saving & loading at runtime.
A wrong frame produces plausible-looking values, so validate a binary frame by
range, not by whether the values look plausible. Reading actorvalue at
name+152 — (name+160)−8, the same field one record along — gives every character
their neighbour's conversation count (the series 0→1→3→5→8→13→21 is Penny's,
not Morrow's, whose is 0→2→3), and that count gates whether anyone ever walks up
to you again. Reading the numeric half from the name lands in the next record's
heap pointers, so the positions look like junk.
actorowner is the one-word memory each character keeps of the player, and it
is a story gate, not decoration: the Purser's whole mission-2 errand is a ladder
of his (none → sendgram → sentgram → left1 → none2 → findcuff → foundcuff → left2), Morrow's permission to enter the wireless room is "enterwireless", and
the chief engineer's turbine job is actorowner("csea"). A save that drops it
brings a wireless checkpoint back with the Purser at "none" — the telegram in
your hand unexplained and his ladder reset — and makes Morrow appear to need
re-persuading after the mission rollover. He does not. resetpupvars() zeroes the
puppet's globals (morrowphase), not actor owners.
Ground truth is the shipped save named for that exact moment: 1/12 - Sending Telegram for Jack Thayer.ti holds purs at c2+3760 with "sendgram" 64 bytes
later, plus morrow → "enterwireless", csea → "thanks1", vlad → "help",
max → "yofrank".
Finding the container is by grid, with one pitfall: the globals container is an array of 32-byte nodes and 32 divides 160, so every fifth node sits one actor stride from the last and a pair of variable names 64 bytes apart decodes as a perfect name/owner record. Three shipped saves (ENDGAME2 09/12/13) prefer it to their real actor container on record count alone, and a patch would then write actor owners over variable names — so the globals container, its pool and the prop container are excluded explicitly.
The whole record is written and restored: the memory of the player (owner, value)
and the placement half — including, since #143, the crowd extras. Unlike the
globals blob, which declares its own storage, the actor container has no
self-declared capacity at all, so it can grow. TI.EXE's save writer dumps the live
actor-list handle, and its loader (0x4143d2) duplicates the read container's
handle straight back into the actor-list global — so the record count is
implicit in the container's size, and one more 160-byte record on the end is
one more actor. That is also why the shipped saves disagree about how many there
are: setupgroup makes the deck extras per room from EXTRA.CST, and the corpus
runs from 25 records to 64, the named cast constant and the extras churning. Every
one of the 109 holds a record for all 25 named characters, so those never want for
a slot; a crowd record the base save lacks is appended. Since the load does
not re-run the room, the file is the only record of the crowd. See
Saving & loading at runtime for what the load
does with them.
The most important container pair for loading holds the script variables. The globals container is a small blob header followed by an array of 32-byte list nodes on a 32-byte grid.
The blob header describes its own storage, which is what makes it safe to write into (measured across all 109 shipped saves):
| Offset | Type | Field |
|---|---|---|
| +0 | u16 | unknown — reads as a node count in 1/01 (96, against 96 names) and cannot be one in 1/04 (92, against the same 96). Never written |
| +2 | u16 | node array capacity: container length = 20 + 32 × capacity, in all 109 |
| +8 | u32 | the string pool's allocation watermark (next free offset) — exactly the end of the highest string any variable points at in 81 of the 109, and past it in the rest (an allocation whose variable was later overwritten) |
| +12 | u32 | the string pool's size — equal to the pool container's length in all 109, but not a constant: 8 of them hold 4102–4105 (1/36 - Found the Notebook!, 2/08 - Showing Pipe to Trask, ENDGAME1/10, ENDGAME2/10 among them) and the rest 2048. The engine does grow the pool, so a from-scratch writer may pick any size as long as the blob declares it |
| +16 | u32 | the pool's heap pointer (rebuilt on load) |
Each node's fields sit at fixed offsets:
| Offset | Type | Field |
|---|---|---|
| +0 | u32 | heap pointer (ignored) |
| +4 | u32 | heap pointer (ignored) |
| +8 | — | name: one length byte + characters, in a 12-byte buffer (trailing bytes are uninitialised) |
| +20 | u32 | DFValue vtable — a raw code pointer, not a constant: 0x00431e0f in all 109 shipped saves, 0x87c4596f in a player's own (#179). It is still what the node grid is found by, but the value has to be read out of the file rather than matched against a fixed one — see Finding the grid |
| +24 | u16 | type tag (2, 3 or 4) |
| +26 | i32 | value (see How wide is the value?) |
| +30 | … | trailing padding |
The C++ object is laid out [DFValue][links][name] — the name comes last in
the struct — so on the 32-byte grid, the DFValue in node k belongs to the
name in node k+1. Reading the name and the value out of the same 32-byte
window (the "obvious" pairing) silently mis-assigns every variable to its
neighbour's value: it yields plausible-looking but wrong numbers (neckphase=3
where the truth is neckphase=5, mission=0 instead of 2) and turns every
string variable into apparent garbage, making type-3 values look like
unrecoverable heap pointers or atom ids — they are not; see below. (Recovered by
decompiling TI.EXE's savegame writer — container N = the live variables blob,
container N+1 = the pool handle stored at blob +0x10 — and confirmed by
same-session DosBox save pairs: after saving on the poop deck the shifted pairing
reads oldset="stair2c", newset="poop"; keyboard bindings decode as
keynorth="w", keyeast="d", keywest="a"; mainpath="titanic2:".)
The node array is located by the word at +20, because it is the one field
whose value repeats across every node. It is not a format constant: it is the
address of the DFValue vtable in the running engine, dumped along with the
object, and it is only stable for as long as the engine is loaded at the same
address.
All 109 shipped saves read 0x00431e0f. A save Nicholas Mischler made in his own
DosBox reads 0x87c4596f — the same game, somewhere else in memory. A reader that
matches the corpus's byte pattern decodes zero variables from it, and a load
applies an empty map: the right room opens with the previous game's mission,
phase and every other global still in place (#179 — Trask still showing you the
clock, a shawl in your hand three missions before it exists). Nothing about that
looks like a parse failure; it looks like a room bug.
So the grid is found by agreement instead: whatever address a session ran
at, all of its nodes carry the same one, and the most common word at +20
across the container wins by about a hundred to one. Nodes whose names are
12–15 characters clobber their own vtable (see below) and are left out of the
vote. nodeVtable in engine/src/df/savegame.ts; the fixture and the regression are
taoot/tests/data/M4P0FCL.ti and taoot/tests/auto/save-original.ts, the one save test
that needs no copy of the rip.
The writer reads the same value rather than stamping a fixed one: a save the port writes patches the file that was loaded, so a record made for a new global goes into a grid that may be foreign. Stamped with the corpus's constant, that node is one neither reader ever finds again.
Two quirks:
- The first name in the list (
clock) pairs one stride BACK, into the blob header, whose bytes past the pool handle are its real DFValue — seedecodeVarSlots, and the measurement in its comment (missions 0–3 read type 3 → "bedsit"; every mission-4 save reads type 4 →hrs*100+min, exactly what BOOTFILE'scalctimewrites there). - Names of 12–15 characters overflow the 12-byte name buffer and clobber
the low bytes of their own node's vtable (
curattention,attentionspan) — a DreamFactory quirk the engine tolerates; validation must skip the clobbered bytes.
Each node's +20..+27 is a serialized DFValue:
- type 2 → boolean, stored inline as the 16-bit 0/1 at
+26. This is what a script'strue/falseproduce, and it is a distinct runtime type, not a second spelling of number: TI.EXE's boolean-taking commands demand exactly tag 2 (propvisible's argument fetch iscmp word [esp], 2at0x416ed8) and its number-taking ones exactly tag 4 (~30cmp …, 4→ error-14 sites). Feeding the wrong tag is the ignorable-but-endless DosBox dialog "A scripting error has occured … [Bad argument type.]" (interpreter error 14 = string 1100+14). Corpus measurements alone cannot tell the two apart, since both carry an inline 16-bit value; loading a port-written save in the real engine and bisecting the dialog down to ten02→04tag bytes does. The port's writer therefore preserves a tag-2 record's tag while the value stays 0/1 (its interpreter carries booleans as numbers, so the tag is the only witness), and lets a non-boolean value retype the record the way an assignment in the original would. - type 4 → number, stored inline as the signed i32 at
+26. - type 3 → string:
+26(unsigned) is the byte offset of the string in the string-pool container that follows the globals container. The pool is a block of[len][chars]entries, 2048 bytes in most saves and about 4100 in the eight the engine grew it in — a live engine structure saved and restored wholesale, which is why the offsets stay valid across processes (this is how the original restores string variables; there is no rebuilt atom table).
Nearly everything in the game's story state fits in 16 bits: a phase, a count, a
clock reading and a pool offset, and the boolean tag carries 0/1. What does not
fit is the other thing a script can put in a variable — a frame() reading.
The counter runs at 20 Hz, so it leaves 32767 behind after 27 minutes of play,
and TAOOT stamps four globals with it: paintframe, lastsail, jonesframe and
secframe.
The node has room for the full dword — the value field runs +26..+30 inside a
32-byte node, and TI.EXE's frame handler (0x4273b0) writes one:
mov ecx, [0x489efa] / mov [eax+2], ecx. The corpus agrees. Across all
109 shipped saves the high word at +28 is 0 in every string (3380 records)
and every boolean (1015), and non-zero in exactly six numbers — every one of
which reads as noise truncated to a word and as the obvious thing at full width:
| Variable | i32 | as i16 |
|---|---|---|
lowmemory |
6144000 (a byte count — 6 MB) | −16384 |
condensor |
40000 | −25536 |
paintframe |
165697 | −30911 |
lastsail |
156350 … 314751 | assorted |
jonesframe |
337079 | 9399 |
secframe |
347697 … 352610 | assorted |
and each of the frame stamps lands a few hundred to a few thousand frames below
its own save's frame counter, which is the
relationship the game reads them for. Truncating paintframe stops the cargo
hold's ten-minute painting timer from surviving a save
(#221).
With the correct pairing, save 20 ("Meeting Conkling in his suite") decodes
completely and self-consistently: mission=2, letterphase=3 (a plain number —
satisfying the B59 knock's letterphase = 2 | letterphase = 3, so Conkling says
"Come in" exactly as the original does), neckphase=5, hrs/min/sec =
10:50:42 PM, hallside="star", savedeck="b", newset="hallb",
oldset="stair1c1", fusebox="1,1,1,1,1,", coalchute="coal4",
savetheme="decka.trk", handitem="". (savetheme is the theme to restore after
an interlude, not what was playing — see
the track containers.)
Loader policy (parseSave):
decodeVars walks the grid with the shifted pairing and decodes both kinds —
numbers into numGlobals, strings (via the pool) into strGlobals; both are
restored into the interpreter on load. Duplicate names keep the first
occurrence (the engine's lookup walks the list from the head).
Writing (applyPatch)
targets the same shifted slot: numbers are written inline and tagged type 4,
strings as a pool offset tagged type 3.
The rule that governs both of the below: a save has to load in the original engine, not only in this port. The blob declares its own storage (capacity at +2, pool size at +12) and TI.EXE allocates from those declarations and copies the containers into them — so a container that has outgrown its own header is either truncated on load (the state silently lost) or copied past the end of its block. This port would never notice: it resolves a string by offset and walks nodes to the end of the container. So nothing here changes the globals pair's length.
The rule is about a header that declares its own storage, not about save files in general — which is why the actor container can be grown a record at a time: it declares nothing, and TI.EXE's loader takes its record count from the container's size.
-
A string the pool lacks is allocated the way the original allocator does: inside the block, with the watermark bumped by
1 + len. It starts at the watermark or past the end of any string a record still points at, whichever is higher — usually identical (both 118 in the template, which has 1930 of its 2048 bytes free), but in 28 of the 109 saves a record holds a stale offset above the watermark, pointing at zeroed space that decodes as""(the blackjack down-cards,saveeast). Writing under one of those would turn that variable's""into whatever landed there. Appending past the container's end instead round-trips in the port but would not survive DosBox. -
A global the base has no record for gets one in a FREE node slot. A
.ticarries the variable list that existed when it was taken and the engine creates a global on first assignment, so an early save simply has no record for a later one:savedeckandhallsideare missing from exactly the four pre-boarding saves of the 109, andshippedSaveTemplatepicks the first file insave/1, which is one of them — without free-slot records, 12 of the globals the engine holds by mission 2 could not be written at all. Every shipped save has free slots (4 in that template, 26 in one of the boiler saves): the array is allocated atcapacityand the engine takes the next slot when a script assigns a new global, which is exactly the gesture being reproduced, at the same stride, inside the same block.The pairing quirk does the linking: the last named node's own DFValue belongs to nobody (dangling, and zero in every shipped save), so a name written one stride past it adopts that DFValue and leaves its own as the next dangling one. Nothing to relink. (It is unused, not necessarily zero: it is zeroed in only 12 of the 109 — elsewhere it holds heap junk with an impossible type tag — and it is overwritten either way.)
The u16 at +0 is not touched, and this matters for whether any of this works in the original engine: it cannot be the list's length, so the loader cannot be walking it as a count, so a record in a free slot is read like any other.
1/01makes it look like a count (96, against 96 names);1/12reads 95 against 112 names, and the 17 past it are live game state —boiler=20000,condtemp=10,electricity=78,saveeast="d". A loader that stopped at 95 would lose the entire turbine plant.
Free slots and pool bytes are finite, so a patch can still leave something out.
Which globals get the slots is ordered by what a load cannot recover any other
way (savedeck, hallside first — they decide where you come back standing),
and anything dropped is reported through SavePatch.onDrop rather than
vanishing silently (a silently dropped zeitclue costs a mission).
Everything above is measured — but measured here, and against the 109
shipped .ti files, not in DosBox. That distinction is the whole risk of
writing saves at all, so here is the split.
From the shipped files (written by the real engine), all 109 unless stated:
- the actor grid, its stride and its
name@+0/owner@+64layout, cross-checked against what the scripts do with those values; globals length = 20 + 32 × capacity(the u16 at +2), and the pool container's length equal to the size the blob declares at +12;- the pool's allocator watermark at +8 — exactly the end of the highest string any record points at in 81 of them;
- free node slots at the end of the array (98 of 109 have at least one; 11 have none, and as a base they would carry nothing new);
attentionspan/curattentionproving that a name longer than 11 characters overflows into its own vtable and the engine tolerates it — which is why a made record writes the vtable FIRST and the name over the top of it, the same collision the original loses;- every
actorownerfitting the 15-character field (the longest any script assigns isreadhackerclue).
The one thing that needs more than shape is whether the loader walks a count, which would make a record in a free slot invisible to it. It does not, and the files say so — the u16 at +0 cannot be a length.
Not verified: TI.EXE's loader itself. The design is built to need as little of
it as possible — no container that declares its own storage changes length, so no
allocation-size assumption is made anywhere; a new string is written past every
offset any record resolves to, so no record's value can change under it; and a new
node is written at the same stride, with the base's own vtable, in space real
saves also leave. The one container that is grown, the actor grid, is grown because the
disassembly says its count comes from its size (0x4143d2) — a claim about the
loader, and so on this list rather than off it. The result should
be indistinguishable from a save the original wrote, pending a DosBox load of
out/checkpoints/m2gram.ti — walk to the wireless room after it and the Purser
should still be expecting his telegram (actorowner("purs") = "sendgram"), with
the staircase showing C deck. opengame is at 0x413860 and the load routine it
calls at 0x414080, which is where to pick up the disassembly if the file alone
is not enough.
Save bugs produce false route "facts", so fix these before trusting a
measurement taken after a load. A checkpoint that drops actor owners suggests
"Morrow has to be re-persuaded after the mission rollover" (he does not), and a
silently dropped zeitclue costs a whole mission.
There is no "location container" — no Pascal-string stream of facing, road,
coordinates and set names forming a stack of location snapshots. The writer's
fixed order emits no such container. A heuristic search for one locks onto the
string pool instead — measured, in 109 of 109 shipped saves — whose entries
are the same facing/side/coordinate strings, in allocation order, because they
are the string globals' values (savestage1-3 and friends).
Nor are fallbacks derived from it needed (hallside from its last
"port"/"star" token, savedeck from the hall set's deck letter). Exactly 4
shipped saves lack a decodable hallside record, all pre-boarding (bedsit1/c73),
and none of their pools hold a side token, because no hallway had ever been
entered — an unset hallside is what a fresh game has until the first hall
assigns one. So hallside decodes from its variable record alone (it still
matters: halla's keydown guard is
if hallside != "star" & hallside != "port" error(), which swallows every key
on an invalid value), and savedeck keeps only the set-derived deck-letter
fallback.
The inventory container serializes the runtime state of every loaded prop —
the inven.shp items first, then the interface/control-panel props — as an array
of fixed 158-byte records.
Every loaded prop, with no filtering, is literally the rule. The writer
(0x413910) walks the engine's live prop list — +0x24 is a node's name, +0x540
its next pointer — and copies one fixed-size record per node until the list runs
out. Measured across the 109 shipped saves: 72 records in every single one,
exactly the two boot shops' props (inven.shp's 28 then house.shp's 44), in
one order across all 109 files, none missing and no extras. So the original
draws no distinction between an inventory item, a story object and a bit of
interface furniture; a save taken with a room's own shop open would simply hold
more records.
Within each record the fields sit at constant offsets. As with the actor grid the
record is the live object dumped verbatim, so the numeric fields come before
the name and TI.EXE's own accessors give the frame — each fetches its record into a
local buffer whose name field sits at buffer+0x4e:
| From the base | From the name | Type | Field |
|---|---|---|---|
| +0 | −0x4e | i16 | propvisible (0x416f30, cmp word ptr [esp+8], 0 — >0 is shown) |
| +0x0a / +0x0e | −0x44 / −0x40 | u16 | the group's logic / script container locations in its SHP — location and location + 1, 7848 of 7848 records against the parsed boot shops. Rebuilt from the name on load |
| +0x12 | −0x3c | i16 | propis3d (0x417760) — strictly 0/1 across the corpus. 1 = placed in the world (propxyz), so the x/y anchor below is stale; the loader restores this flag, since the live one is whatever the running game last did (TAOOT's watch/bag: 1 in exactly the 4 pre-boarding saves where they still lie on the cabin furniture, 0 in the 105 where they sit in the band) |
| +0x14 / +0x16 | −0x3a / −0x38 | i16 | propxy (0x4175c0): +0x14 is the screen Y and +0x16 the X. The interface band's props all read x = 256, y = 324 — the band anchor |
| +0x18 | −0x36 | i16 | propdeg (0x4168a0, movsx ecx, word ptr [esp+0x20]) |
| +0x20 | −0x2e | i16 | current frame within the view — a valid index in 7848 of 7848 (0 ≤ frame < max(1, play length)) |
| +0x22 | −0x2c | i16 | play length of the current view: the frame count for a real animation, 1 for a deg-selector state (which shows one frame) — 7404 of 7848; the misses cluster on states whose animated bit the engine decides at play time |
| +0x24 | −0x2a | i16 | propspeed (0x416b20) — 4 in every record ever written; nothing changes a prop's speed |
| +0x26 | −0x28 | i16 | propdist — z-order, more negative = nearer |
| +0x28 | −0x26 | i16 | propscale (0x416a90) — 10 distinct values in the corpus, about 1.1 per prop |
| +0x46 | −0x08 | u32 | propvalue (0x416240) — 0 or 1 in the corpus |
| +0x4a | −0x04 | i16 | propzclip (0x4162d0) — 7 distinct values |
| +0x4e | +0 | pstr | prop name |
| +0x7e | +48 | pstr | current propview state (0x416610, lea ecx, [esp+0x86]) — "large", "panel1", … |
| +0x8e | +64 | pstr | propowner (0x4161c0, lea ecx, [esp+0x96]) — "frank" = in Frank's possession, else "none"/"vlad"/"purser"/… |
The offsets that matter for reading are the ones from the record base, because
that is what the getters use; the second column is the same field measured from the
name, which is what walkPropGrid
locks the grid onto — so every numeric field is at a negative offset there, and
every read of one is bounds-checked at both ends.
view and owner are 16 bytes apart, which is what fixes the name at +0x4e and
makes the two empirically-recovered offsets (+48/+64) and the disassembly agree.
Both string fields hold a length byte + up to 15 characters — the longest owner
in the whole corpus is "xxxfrank" (8) and the longest view is
"deckbd-wireless" at exactly 15.
propvisible checks out by range against the corpus, on the one prop where it can
be read against something independent: the baby is visible = 1 in exactly the 3
saves whose propowner is "frank" and 0 in all 106 others. propdist gets the
same kind of independent check from the open pocketwatch, whose four pieces read
−6/−5/−5/−4 for lid/hrs/min/sec — exactly the z-order stack the watch's own open()
assigns them, in that order.
Still structured but unnamed — all engine bookkeeping, none of it restorable
state: +0x1a–+0x1e (animation timing scratch — non-zero only on the two
ever-animated big-scale props, the watch and the bag, with tick-sized values),
+0x30 (a per-prop constant across all 109 — 0 for the inventory items, small
ordinals for some interface chrome; not the state index, not the shop-wide state
ordinal, both tested), and +0x34…+0x42, eight consecutive small words that
look like two screen rects (last-drawn bounds). The pointer columns are
+0x02/+0x04, +0x06/+0x08 (always 0x9f/0xa6 heap) and +0x2c. As with
the actor record, everything a load could use is already named.
The two disks differ in how a collected item is shown (disk 1 stows to panel1;
disk 2 keeps it large), so the loader restores the raw owner+view verbatim
per item rather than interpreting them — the inven.shp scripts read exactly
those fields to draw the bag and quick-slots. decodeVars' sibling
walkPropGrid locks onto the 158-byte grid (the same anti-junk technique as the
variable grid) and yields one record per slot — name, view, owner and the whole
numeric half.
applyPatch overwrites a record in place, in records the original wrote: the
two string fields (view, owner) and the whole numeric half — visible, the
screen anchor, deg, dist, scale, value and zclip. A prop with no record
in the base is skipped: unlike
the actor container, this one
is not grown, and the extras it would take — the props of a room's own shop, on top
of the two boot shops' 72 — are furniture that room rebuilds anyway.
inventorySnapshot offers every prop the engine has loaded, in the engine's own
list order — the same rule as above; a hand-kept list misses props (the
bag/pocketwatch/deck map, baby; see runtime/saves.md).
Since #143 the load runs no room script — no showinterface, setupsigns or
setuparrow — so the file's values are the restore, and a field left
unwritten is a field the base save gets to decide. The port therefore writes every
one of them. (Measured against four saves spanning the game, the port's owner
agrees with the file for 72 of 72 props while the view disagrees for 6–8
— navtoggle, subtoggle, invenctl, lid, invenhelp, door, signs,
wiremsg — fields those room scripts would otherwise re-derive.) The one field
still withheld is a view the port has never set: an untouched prop is
sitting in its file default and "" is not a reading, so the base's stays.
The last three fixed containers are the master service pass's own tables,
dumped verbatim. 0x442530 sits directly in front of the service pass
0x442550 and hands back pointers to 0x48bcd0 (loops), 0x48b830 (crickets)
and 0x48b150 (walks); the writer memcpys all three, which is why their
lengths are constants — 1344 / 1184 / 1760 — in every save. They are not
searched for by those sizes; they are the tail of the computed map, and
saveIndex
uses the triple the other way round — as the check that the map landed where it
should.
There is no separate "puppet container". Parked-conversation state is the
walks table plus its per-walk payload — a character mid-walkto is the only
"in-flight" thing the format carries.
| Offset | Type | Field |
|---|---|---|
| +0 | u16 | active — the slot is free if 0 |
| +2 | u16 | busy — in-service reentrancy flag; 0 in every shipped record |
| +4 | u16 | kind: 1 = actor, 2 = prop, 3 = scene, 4 = flat (0x4449f0) |
| +6 | u32 | period — ticks remaining, mid-count, on the engine's 50 ms service step |
| +10 | pstr16 | name — the actor/prop/scene the loop belongs to |
| +26 | pstr16 | handler script |
It decodes cleanly in 109 of 109: actor ga → gaidle, actor purs → playcrickets, scene scene49 → smethknock at per = 278, the stoker's dig
loops, the extras' extraidle — every record a real script at a sane countdown.
This table holds the idles that make characters act and the scene timers, which
a load would otherwise have to rebuild by re-running the arriving room's
openset.
| Offset | Type | Field |
|---|---|---|
| +0 / +2 | u16 | active / busy |
| +4 / +6 | i16 | x / y |
| +8 | u32 | radius |
| +0xc | u32 | base period |
| +0x10 | i32 | jitter (−1 = none, i.e. a one-shot) |
| +0x14 | u32 | next fire = base + rand(jitter), mid-count |
| +0x18 / +0x1a | i16 | listener x / y at make-time |
| +0x1c | u32 | distance to the listener |
| +0x20 | u16 | pan / angle (0x444b70(dx, dy)) |
| +0x2a | pstr16 | the set it was made in (the global at 0x489f94) |
| +0x3a | pstr16 | cricket / sound name |
The corpus holds 15 distinct crickets, all real room ambience: party1 at
(5683, 148) with rad = 2000 in deckbd2, piston3 at rad = 7000,
jit = −1 in engine, citycricket at base = 45 in bedsit1. The four
fields from +0x18 on are the service pass's own working state — listener
position, distance and pan at the moment the cricket last fired — and are
recomputed the next time it does, which is why the port writes zeros there and
a centred pan.
A walk record is 110 bytes (+4 type, +0xa deg, +0xc/+0xe/+0x10 xyz, +0x16
progress, +0x2e actor). +0x12 is a handle to the walk's waypoint path, and
only type-3 walks have one. Each active slot with a non-null handle appends
one payload container after the walks table:
| Offset | Type | Field |
|---|---|---|
| +0 | u32 | total path length |
| +4 | u32 | (unread; 0 in every authored path and every shipped payload) |
| +8 | u32 | waypoint count |
| +12 | 4 × i16 | the path's bounding box — (Zmin, Xmin, Zmax, Xmax), see below |
| +20… | count × 8 | waypoints: i16 x, y, z, u16 segment length |
The block is the set file's authored path structure, verbatim — the same
{total, 0, count, bbox, points} that readStarPath (engine/src/df/set.ts) reads out
of a star record's path container. walkonpath COPIES the authored block and
adjusts the points: snapped to the live stars' positions, reversed when the
walker starts from the b end (2/33's hack walks his authored path
backwards), legs and total recomputed to match. The box is copied unchanged,
which is why the shipped payloads' boxes fit their authored polylines exactly —
byte-identical at +12 with deckbd's ga.1→ga.2 and scot3's hack1→hack2 —
and miss their own runtime points by the width of the snap. It reads as noise
unless compared with the set file's authored path.
The container is the raw allocation, so a row or two of slack trails it. The
loader (0x4149bd) memcpys the walks table back and then, for each active
slot with +0x12 ≠ 0, reads the next container and stores the new handle back
at +0x12 — which is what closes the round trip and also proves the payloads
are in slot order.
Exactly 3 of the 109 shipped saves carry one, and their trailing containers
match: 1/20 - Meeting Conkling in his suite - B59 (36 bytes),
2/05 - Talking with Max (108) and 2/33 - Got clue from Jack Hacker (92). In
2/05 it is ga's 10 waypoints marching x = 4498 → 6787 at constant z — a
mid-walkto across the boat deck, serialized.
The port resumes a walk from all of this — see Loading, step 13. The record holds the origin, the deltas, the distance and the progress, and a route holds its waypoints and its length here, so the walker sets off from where the save caught them with only what was left to run. Rebuilding the position from these fields lands on the actor record's own for every live slot in the corpus, which confirms the reading.
Two things to hold on to. The type is which mover, and only type 1 fills the
record's movement words in: a type-0 turn has no mover, and a type-3 route keeps
its length in the payload, so both leave those words holding whatever the slot
held last (hack's route claims a distance of −1422655421). And a walk that
cannot be put back is dropped and its walker stood up — an actor steps
through its pose's play script whether a walk is running or not, so a drop that
left the walk pose alone leaves a character treadmilling.
The port writes one too (#191).
A base slot left active would send the original's loader looking for a payload
container belonging to the previous save's moment, so the walks table must be
written consistently — and a save mid-conversation-approach has a live walk
(walktopuppet is a walk, and it is how most characters reach you).
What writing one takes, beyond filling the slot:
- The payload is appended, one per type-3 slot, in slot order — the order being the only thing that matches a payload to its slot. This is the single field a save patch writes that does not fit a slot the base already has; every other one does. The base's own payloads are dropped rather than left as tails.
+0x12is a flag, not a pointer to forge. The shipped values are DOS heap addresses TI.EXE allocated (0xa6b4b0,0xa6c1f0,0xa6d820), and the loader stores its own handle back over the word, so any non-zero value does. The port writes one of the real ones, so a save it writes is shaped like a save TI.EXE wrote.- Every header field is written with its meaning, including the box.
+4is 0 everywhere.+12is the bounding box above; the port computes it over the points it writes — exact where TI.EXE's own copy is stale. And the box is provably inert on a resume: the mover's position function (0x444d70, reached from the walk service's type-3 branch at0x443f14) reads the total, the count and the waypoints, and never touches+4or+12— in either of its branches, mid-path interpolation or arrival. The box serves the path lookup atwalkonpathSTART, which queries the set's own registry, never a save's payload.
All 16 live slots across the 12 shipped saves that have one are written back and read
again unchanged — all three shapes. Beyond that round trip, DosBox confirms TI.EXE
reads the port's output: one save per shape, written by this writer mid-flight and
opened in TI.EXE — cash mid-walktostar on the Grand Staircase, ga
mid-walkonpath across the boat deck (the appended waypoint container, box and
all), max mid-turntodeg — and all three resume and finish their walks in the
original engine (#191).
Container 6 lists the open tracks as 40-byte descriptors, and each descriptor is followed by three containers of its own further down the file:
| Offset | Type | Field |
|---|---|---|
| +0 | u32 | heap pointer |
| +4 / +6 / +8 | u16 | the three array counts: registered / playing / looping |
| +0xa / +0xe / +0x12 | u32 | the three arrays' heap pointers |
| +0x16 | pstr16 | track name — inven.trk, unilib.trk, cricket.sfx, deckbd.trk |
The counts match the following containers' record counts in every save, and that
is the half of
saveIndex's
validation that catches an open-track count read one too high or one too low —
the map is positional, the descriptor's own counts are what confirm it. Each of
the three arrays is a grid of 104-byte sound records:
| Offset | Type | Field |
|---|---|---|
| +0 | u16 | the chunk's container location in its bank (01 main in CARGO.TRK lives at container 2, and its record reads 2) |
| +2 | u16 | 0 in every playing/looping record; the registered arrays hold small ordinals |
| +4 | u16 | volume (255 default) |
| +6 | u16 | pan (128 = centre) |
| +8 | pstr16 | the chunk identifier, verbatim from the bank (01 main, Boat Deck, doorlocked) |
The rest of the record is heap junk. Array 1 is the track's registered sounds
(where volume/pan are the last play's — the positional motor cricket sits at
volume 9 / pan 0x8a, attenuated exactly as it was last audible); arrays 2 and 3
are the playing and looping lists, and they are the answer to "what was this
room sounding like": a boat-deck save carries Boat Deck in both.
The descriptor list answers a different question from the playing/looping
arrays, and a load must read both
(#199). A bank can be open with
all three of its arrays empty and still be the bank a restored makeloop or
makecricket reaches into: BOOTFILE's playcrickets opens insddest.sfx once
when mission 4 starts, then picks a random one-shot out of it every few seconds,
so the sinking's groaning metal is a live loop over a silent bank.
Measured over the corpus: a .sfx bank is never the playing theme in any of
the 109 shipped saves, 33 of them hold one, and across the 18 with a live cricket
table 49 of 50 cricket records cannot resolve their sound from the theme's
bank alone. All 50 resolve from the banks the descriptor list names.
It does not heal by walking, either. setupsound only calls setupcrickets()
when crickettype(currentset()) changes, and lnghall, lounge1c and smoke are all
"insd" — so a load that skips the bank logs sound not found: with an empty
name (countsounds 0 → indextosound "") for the rest of the game.
savetheme is not the playing theme. The script global of that name records
the theme to restore after an interlude, and measured against all 109 shipped
saves it lags the file's track state in 91 of them. The playing theme is the
one track whose playing/looping arrays are non-empty — exactly one track in every
shipped save. That is the value a load restores.
The two live lists are not free-form. Measured across all 109 shipped saves
(111 live tracks, 2583 records, no exceptions): the playing list is one record
per loop-table record of the bank, in table order, and the looping list is
one record per entry of the bank's play order, each a copy of the playing
record the order entry names. idx is the chunk's container location, +2 is 0,
pan is 128, and the name is the chunk identifier — the shipped cargo-hold save
carries 01 main…11 end, idx 2–12, counts 0, 11, 11.
The reason is the loader's other half. opengame's post-restore resume
(0x414a70) pairs playing record n with the bank's own loop-table record
n — the save's record contributes only volume and pan — and then rebuilds
the looping list by copying playing[order[n] − 1] for every entry of the
bank's play order, bounded by the bank's chunk count (the u16 at
loop table + 0x10a), not by the save's counts. The arrays were allocated from
the descriptor counts at +6/+8, so a playing list shorter than the bank's
tables is an out-of-bounds read and write on 104-byte-stride heap blocks. A
single invented record there (idx=1, name="cargo") makes TI.EXE allocate 104
bytes per list and copy eleven records through both, and the smashed heap
surfaces as "Memory error at line 301 (code 2): Unknown compression format" —
the codec-table lookup at 0x401539 (raise 0x435160, error 999) reading a
clobbered sound header. The port loads the same file happily, which is why
what is verified insists a save has to load
in the original engine, not only here.
So applyPatch writes the lists from the bank itself: SavePatch.theme carries
the loop records (container location + identifier) and the play order, supplied
at save time by the open bank (AudioLibrary.loopTable), and the descriptor
counts move with the container lengths as always.
Writing it back has one real limit. applyPatch can empty every track's
playing/looping arrays and write one base track's lists (descriptor counts and
container lengths move together, so the file keeps the shape the original writes),
but it cannot open a track the base save never had: the container-0 manifest
names the open files and the patcher does not rewrite the manifest. A theme whose
track is not in the base is dropped, reported through SavePatch.onDrop, and the
room loads silent.
It restores the engine from the file, and runs no room script at all. That is
what the original does — opengame (0x413860 → the restore at 0x414080)
rebuilds the room through the engine's own set machinery and never reaches the
script runners — and since #143 it is what this port does too. The alternative,
arriving in the room through initall (changeset + initactors + initprops)
and letting the openset/openscene scripts rebuild state, is described with
the bug that ruled it out — a conversation opening inside a load
(#125) — in
Saving & loading at runtime.
What a load takes out of the file:
- the globals, numbers and strings, plus the
hallside/savedeckfallbacks; - the open cast files (container 3), reopened before any record is applied — see the crowd;
- the cast, wholesale — the live actor list is wiped and every record applied,
including the crowd extras, which are re-instanced from their cast member by name
(
brown1a1←brown1,stok4←stok1), and includingactorscalefrom the record's +42; - every prop, both halves — owner and view, plus visible, the screen anchor,
deg,dist,scale,valueandzclip, which is how the band's lit-or-dark, the nav arrow's colour, the destination signs and the open pocketwatch's whole assembly come back withoutshowinterface/setupsigns/setuparrowrunning; - the open audio banks (container 6), every one of them — the restored loops and crickets play out of banks that need not be sounding, see an open bank is not a playing bank;
- the loop and cricket tables, mid-count, straight into the scheduler;
- the theme, from the track state rather than from
savethemeor a re-score; - the walks table, resumed mid-stride — turns, straight lines and waypoint routes alike (see the walk record's payload); a walk that cannot be put back is dropped with a log line naming the character, who is stood up out of the walk pose at their restored position.
The set/scene/view is then opened through the engine's set machinery with the whole
lifecycle muted — no closeset on the way out, no openset/openscene on the way
in. The scene is still recorded as current, so the first turn or step fires
openscene normally. See
loadGame/snapshotSave
and the savegame/opengame builtins in
engine/src/runtime/builtins/savegame.ts.
Writing is the same list from the other side, since a round trip has to feed
that loader. snapshotSave patches a base save (the last one loaded, or a
host-supplied per-disk template) with the current globals, the set/scene/view and
the set file's manifest path + register refs (see
the loader re-opens the room from the manifest),
every loaded prop's full record, every actor's full record — appending one for a
crowd extra the base save lacks — the scheduler's loop, cricket and walk tables (a
walkonpath appending its waypoint container), and the playing theme
(one record per loop chunk of the bank).
Everything the loader ignores stays byte-for-byte as the base had it.
Back to the format index, or on to how the running game uses this: Saving & loading at runtime.