- .gitignore: ignore /extracted/ (regenerable game data) and /tmp/ (scratch) - git rm --cached the 38 extracted/ files: untracked but left on disk, not deleted - master_scrape.py: add GetExpedition=7 to ACTIONS (was missing; pulls ExpeditionDto.Trampler) - docs: mark master-server /connect blocker cleared 2026-06-16 (server back up); server-side upsert hash validation remains untested (live re-test not yet run)
150 lines
10 KiB
Markdown
150 lines
10 KiB
Markdown
# Trampler / walker blueprints — structure, hashes, footprints, rendering
|
||
|
||
All findings from this session. Data-derived unless marked otherwise. Tooling in `reverse/`.
|
||
|
||
## Where blueprints come from
|
||
- **Your own trampler**: master-server `GetTrampler` (Action 16, `Message`=characterId) or PlayFab
|
||
`TitleData` (`TramplerBlueprint3`, a default). Shape = `WalkerBlueprintDto`.
|
||
- **Another crew member's / the host's**: `GetExpedition` (Action 7) → `ExpeditionDto.Trampler`
|
||
(a `WalkerDto`), or `GetExpeditionWalker(characterId)`. `WalkerDto` is the same build in a
|
||
slightly different wrapper (see conversion below).
|
||
- **Local saved walkers**: `.wbt` files in `…/Hologryph/Sand/Data/Walkers/*.wbt` (slightly different
|
||
serialization — parsed separately; not covered here).
|
||
|
||
## `WalkerBlueprintDto` (the blueprint)
|
||
```
|
||
Id, UniqueId, Version,
|
||
Chassis: CompartmentBlueprintDto,
|
||
Compartments: [CompartmentBlueprintDto], # ORDER IS MEANINGFUL = the game's order (matches Id field)
|
||
Connections: [ConnectionBlueprintDto],
|
||
CompartmentsHash, DefinitionsHash, ConnectionsHash # 3 MD5 hashes
|
||
```
|
||
- `CompartmentBlueprintDto = {Id, EpbId, CellCoordinate{x,y,z}, DecorationsInfo, Rotation,
|
||
CompartmentHash, DefinitionHash}` — `CellCoordinate` is the **origin/anchor cell**; `y` = floor.
|
||
- `ConnectionBlueprintDto = {Id, EpbId(null), ActualDirection{x,y,z}, GridCoordinate{x,y,z},
|
||
SlotType, State}`.
|
||
- Compartment **order = the game's order** (verified: cargo C1–C8 matched in-game). Same as the `Id`
|
||
field; arrays preserve it.
|
||
|
||
## `WalkerDto` → `WalkerBlueprintDto` conversion (`reverse/walkerdto_to_blueprint.py`)
|
||
`WalkerDto = {Id, FirstNameIndex, SecondNameIndex, BlueprintId, BlueprintUniqueId, Chassis,
|
||
Compartments:[CompartmentDto], Connections:[ConnectionDto]}` where `CompartmentDto={Id, Blueprint}`
|
||
and `ConnectionDto={Id, Blueprint}`.
|
||
- The WS form serializes **enums as ints and omits null EpbId**; the blueprint/hash ("storage") form
|
||
uses **enum NAME strings** and includes `EpbId:null`. Convert: unwrap `.Blueprint`, map enums
|
||
int→name, add `EpbId:null`. Enum maps (from dump.cs): `ConnectionSlotType`
|
||
DOOR0/HATCH1/STRUCTURE2/BALCONY3/DECK4; `ConnectionState` DEFAULT0/DOOR1/OPEN2;
|
||
`ConnectionsCount` FULL0/PARTIAL1/ERROR2.
|
||
- Verified by a **storage→WS→storage round-trip** on the sample (byte-identical, hashes reproduce).
|
||
|
||
## The 5 hashes (`reverse/trampler_hashes.py`, `walker/walker_hashes.py`)
|
||
`MD5Utility.ComputeHash(obj) = MD5( UTF8( JsonConvert.SerializeObject(obj) ) )` → **uppercase hex,
|
||
no separators** (`X2`). Newtonsoft compact: no whitespace, PascalCase, declaration-order keys, nulls
|
||
included, enums as **name strings**. A `WalkerBlueprintDto` carries **3 top-level** hashes; each
|
||
`CompartmentBlueprintDto` (chassis + every part) carries **2 more**.
|
||
|
||
| Hash | Scope | What it hashes | Offline-computable? |
|
||
|------|-------|----------------|---------------------|
|
||
| `CompartmentsHash` | top-level | `MD5(compact-JSON(Compartments list))` | **YES** — VERIFIED |
|
||
| `ConnectionsHash` | top-level | `MD5(compact-JSON(Connections list))` | **YES** — VERIFIED |
|
||
| `DefinitionsHash` | top-level | `MD5(JSON(Compartments→CompartmentDefinitionDto))` | **NO** — server-sourced |
|
||
| `CompartmentHash` | per-part | placement fingerprint (constant per EpbId+placement) | **NO from scratch** — harvest |
|
||
| `DefinitionHash` | per-part | `MD5(JSON(that part's CompartmentDefinitionDto))` | **NO** — server-sourced |
|
||
|
||
- The two **Definition** hashes (`DefinitionsHash` + per-part `DefinitionHash`) hash the rich
|
||
server-side `CompartmentDefinitionDto` — which is **NOT** in the blueprint and **NOT** equal to the
|
||
server's `GetCompartmentDefinitions` *pricing* DTO (brute-forcing every field subset of that DTO
|
||
reproduced neither). They can only be **harvested** (every part placed in the in-game editor writes
|
||
them into the `.wbt` save) → `extracted/definition_hashes_known.json`, `walker/harvest_hashes.py`.
|
||
- `CompartmentsHash` is a hash **over the Compartments list including each part's `CompartmentHash` +
|
||
`DefinitionHash` fields** — so wiping per-part Definition hashes also changes `CompartmentsHash`.
|
||
- Per-part `CompartmentHash` is **constant per EpbId** across placements (verified across 30 harvested
|
||
EpbIds); per-part `DefinitionHash` is constant per EpbId **except** it varies with `DecorationsInfo`
|
||
content (only `walker_compDeck_Square_Open_1x1_epb` seen to differ so far).
|
||
|
||
## Hash lifecycle & purpose — round-trip experiment (2026-06-16, live client)
|
||
Test: built copies of a known walker offline with hashes wiped, loaded them in the game, watched
|
||
`Player.log` (`CheckValidBlueprint: ERRORS {0}, VERSION:{1}, {2}` = `haveBuildingErrors`,
|
||
`isVersionValid`, `UniqueId`), then opened/saved in the in-game editor and re-decoded.
|
||
|
||
| Variant | Comp/Conn hashes | Definition hashes | `ERRORS` | `VERSION` |
|
||
|---------|------------------|-------------------|----------|-----------|
|
||
| Definition hashes only wiped | valid | wiped | False | **True** |
|
||
| **all 5 wiped** | wiped | wiped | False | **False** |
|
||
| untouched original (current build) | valid | valid | False | True |
|
||
| genuinely old-version walker | valid | valid | False | False |
|
||
|
||
Findings (data-derived):
|
||
- **The client recomputes ALL FIVE hashes locally on save** from its own `CompartmentsDatabase` +
|
||
`CompartmentDefinitionDto` container. Saving an all-wiped walker produced hashes **byte-identical** to
|
||
the original source (same build ⇒ same hashes). So the hashes carry **no per-walker secret** and are a
|
||
pure function of the build.
|
||
- **Open/edit tolerates blank hashes**: an all-wiped walker loads, lists, and opens in the editor with
|
||
no error; opening + closing **without saving rewrites nothing** (file stayed byte-identical). Hashes
|
||
are (re)written **only on save**.
|
||
- **Every save mints a new file UUID *and* a new `UniqueId`** (observed 3×).
|
||
- **`isVersionValid` (the `VERSION` flag) depends only on the structural hashes** (`CompartmentsHash`/
|
||
`ConnectionsHash`), **not** on the Definition hashes (def-only-wiped was still `VERSION:True`).
|
||
`haveBuildingErrors` (`ERRORS`) is a separate structural check, independent of every hash.
|
||
|
||
Purpose (mechanism from `dump.cs`, not inferred): `WalkerBlueprintContainer.ValidateVersion(
|
||
ICompartmentsDatabase, ICompartmentDefinitionDtoContainer)` sets `isVersionValid`/`haveBuildingErrors`.
|
||
It takes the **current** database + definitions as input ⇒ the stored hashes are a **cached fingerprint
|
||
re-checked against a fresh recompute**: when the game's part data changes across a patch, the recompute
|
||
won't match the stored hash and the walker is flagged stale (`VERSION:False`, → migrate/re-save). They
|
||
are **integrity / version-staleness markers, not security** — plain unsalted MD5 we reproduce offline;
|
||
the client just regenerates them. From the *client editor's* view the **Definition** hashes appear inert
|
||
(no effect on `ERRORS` or `VERSION`); whether the **server** validates them on upsert is still untested
|
||
(the master-server `/connect` blocker is cleared as of 2026-06-16 — server back up — but the live
|
||
re-test has not been run yet).
|
||
|
||
Practical upshot for offline editing: edit the `.wbt`, then either recompute the two structural hashes
|
||
yourself (`walker/walker_hashes.py`, exact) or just let **one in-editor save** regenerate all five.
|
||
|
||
## Footprints (`extracted/CompartmentsDatabase.json` → `compartments[].cells[]`)
|
||
Each compartment def has `cells[] = {position{x,y,z}, sockets{dir:{slotType:…}}, volumeOccupied,
|
||
requireSupport}` in the compartment's **local frame**. World cells = rotate local `(x,z)` by the
|
||
blueprint `Rotation`, add origin; `y` world = origin.y + local.y.
|
||
- **Rotation convention (verified by 0-overlap check across 56 solid cells):**
|
||
`90→(z,-x), 180→(-x,-z), 270→(-z,x)`.
|
||
- `volumeOccupied=true` = solid cell; `false` = non-solid (e.g. the reactor's north half-circle dome).
|
||
- Reactor `Round_Metal_2x1` is actually a 2-wide tower **rising y0→y5** with a 2×2 solid base + a
|
||
1-row dome to the north — the EpbId "2x1" is not the real footprint.
|
||
|
||
## Connections (doors / hatches) — blueprint `Connections[]`
|
||
Per slot: `GridCoordinate` (world cell), `ActualDirection` (face), `SlotType`, `State`.
|
||
- **DOOR** (horizontal, `dir.y==0`): `State` DOOR/OPEN = real door, DEFAULT = wall.
|
||
- **HATCH** (vertical, `dir.y==±1`): `State` DOOR = hatch between floors.
|
||
- STRUCTURE / DECK / BALCONY = structural/walkable connections.
|
||
|
||
## Weapons
|
||
- `BattleRam_2x2` = **rams** (no firing arc — battering rams). `TurretSlot_*` = turret mounts;
|
||
`…OpenCorner…`/`…MetalCorner…` = **45° corner** mounts, others = straight/cardinal.
|
||
- **Firing facing is NOT in the data** (weapon `Properties` empty; `CompartmentsDatabase` has no
|
||
aim/forward/angle field). The renderer derives facing from the **`Rotation` field** + a
|
||
user-supplied calibration (CALIBRATION, not pure data): straight rot0 = South, corner rot0 = SW,
|
||
+90° = clockwise → `face = base − rotation` (base: straight 90°, corner 135°), arc = ±90°.
|
||
|
||
## Renderer (`reverse/render_trampler.py`)
|
||
Per-floor (y −1…3) PNG: real footprints (solid vs faded non-solid), doors/hatches, single-tile guns
|
||
with ±90° firing wedges, rams, cargo numbered **C1–C8** in JSON order. Outputs
|
||
`extracted/host_trampler_*.png`. Reactor column above deck 3 and legs below the base are clipped.
|
||
|
||
## Other scrape tooling (this session)
|
||
- `reverse/master_scrape.py` — master-server replay (two-socket `/login` + `/connect`; see
|
||
[MASTER_SERVER.md](MASTER_SERVER.md)).
|
||
- `reverse/{capture_hosts,ws_scrape,playfab_scrape,noise_filter,resolve_decomp}.py`.
|
||
- Ghidra: `analyzeHeadless … -postScript decomp_targets.py|disasm_targets.py` (offset bug fixed:
|
||
rva = il2cppdumper `Address` directly, **no −0x1000**).
|
||
|
||
## Open questions
|
||
- **Server-side upsert validation — UNTESTED.** We've only confirmed the *client* tolerates wiped/blank
|
||
hashes (loads, opens, edits, and regenerates all 5 on save). Whether the **master server** validates
|
||
the hashes (esp. the server-sourced `DefinitionsHash` / per-part `DefinitionHash`) when a blueprint is
|
||
`UpsertTrampler`'d — i.e. whether it recomputes, rejects, or stores blanks — is not yet known. It was
|
||
previously blocked by the master server throwing `System.NullReferenceException` and closing the socket
|
||
on `/connect` (no successful login); **that blocker is cleared as of 2026-06-16 — the master server is
|
||
back up**, so the re-test is now unblocked but **has not been run** (awaiting explicit go-ahead for the
|
||
live op). Re-test: upsert a walker with Definition hashes blank, then `GetTrampler` it back and diff.
|
||
See [MASTER_SERVER.md](MASTER_SERVER.md).
|