Files
SandTools/docs/TRAMPLER.md
DownloadPizza f049db6921 gitignore + untrack extracted/ (kept on disk); master-server GetExpedition op
- .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)
2026-06-16 13:45:55 +02:00

150 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 C1C8 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 **C1C8** 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).