Files
max bittker fb26497163 Harden Miscellania transfers and add coin dupe regression coverage
Follow up kadeski’s fix in PR #73 with current-balance and inventory-capacity checks. Keep the original report and authorship credited in the vendor patch checklist. Exercise both reported exploit inputs in compiled-script tests, and build engine content for those tests in CI.
2026-10-11 19:37:42 -04:00

441 lines
32 KiB
Markdown
Raw Permalink 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.
# Vendored-Tree Monkeypatch Checklist
`server/{engine,content,webclient}` are vendored copies of upstream LostCity (rev 289, upgraded
from 274 on 2026-10-09; branches `289` in each upstream repo).
Every local modification ("monkeypatch") is listed here with a verification step.
**Walk this checklist after every vendor sync/rebase** — history shows patches don't get
dropped wholesale, they get subtly severed (see "Cross-boundary invariants" below).
How the vendoring works: upstream clones with remotes live at `../repos/{engine,content,webclient}`;
each `vendor-289` branch = upstream tip + ONE squashed "rs-sdk local mods" commit (`vendor-274`
is kept as the previous era's backup). The systematic
audit (compare the mods commits between old and new vendor branches, file-level + added-line
survival) is described in the project memory; this file is the human-readable checklist.
---
## Engine (`server/engine/`)
### Protocol / custom packets
- [ ] **Global chat broadcast** — `MessagePublicHandler.ts` broadcasts public chat to all
players outside the 14-tile overhead range via custom `MESSAGE_PUBLIC` packet
(opcode **255**, variable length; still free after 289 reshuffled every opcode). Pieces: `ServerGameProt.ts` (opcode),
`ServerGameProtRepository.ts` (binding), `codec/MessagePublicEncoder.ts` (p8 userhash +
WordPack), `model/MessagePublic.ts`.
Verify: `grep -n "MESSAGE_PUBLIC = new ServerGameProt(255" src/network/game/server/ServerGameProt.ts`
**⚠ MUST pair with the webclient receive branch (see webclient section). An engine-side
packet with no client handler causes a T1 LOGOUT on the receiving client.**
- [ ] **Custom opcodes vs upstream renumbering** — every revision bump reshuffles BOTH opcode
tables (289 changed nearly every value). After a sync, confirm server opcode 255 and
client opcode 243 are still unused in upstream's `ServerGameProt`/`ClientGameProt`
(and webclient `ServerProt`/`ClientProt`), and that `ServerProtSizes[255] === -1`.
### Config / environment
- [ ] **`Environment.ts`** — flat back-compat aliases over 274's nested `WorldConfig`, plus
`migrateFromLegacyEnv(loadWorldConfig(), process.env)` overlay so fly.io `[env]` vars win
over `world.json`. Also `NODE_WS_ONDEMAND` **defaults true** (274 client streams assets
over the game WS; false ⇒ stalls at ~60% "Connecting to update server").
- [ ] **`WorldConfig.ts`** — default `web.port = 8888` on all platforms; `xpRate = 25`.
- [ ] **400-char chat** — `WorldConfig.ts` default `node.maxMessageLength = 400` (classic 80).
Paired pieces, all required: `wordenc/WordPack.ts` caps follow
`Environment.node.maxMessageLength` (not hardcoded 80) AND clamps packed output to
240 bytes (`truncateToByteBudget` — all chat frames carry a 1-byte length; 2-nibble
chars would overflow it and corrupt the stream); `MessagePublicHandler.ts` /
`MessagePrivateHandler.ts` use `Packet.alloc(1)` not `alloc(0)` (packed chat can be
~250 bytes; the 100-byte tier throws RangeError mid-cycle and kicks the player).
Mirrors in webclient (see below) and SDK chunking (`sdk/index.ts` packedNibbles).
Verify: `grep -n "maxMessageLength: 400" src/util/WorldConfig.ts && grep -n "alloc(1)" src/network/game/client/handler/MessagePublicHandler.ts`
- [ ] **`World.ts`** — connection timeouts relaxed for bot background tabs
(`TIMEOUT_NO_CONNECTION` 5m / `TIMEOUT_NO_RESPONSE` 10m, gated by `NODE_DEBUG_SOCKET`).
- [ ] **`World.ts` tick drift cap** — `cycle()` clamps `nextTick` to at most 2 ticks of
backlog before computing `drift`, so the world resumes normal pacing after sustained
overload instead of sprinting through the whole backlog at max speed.
Verify: `grep -n "start - this.tickRate \* 2" src/engine/World.ts`
### Old-client guidance (rs-sdk)
- [ ] **Outdated-client record** — `World.ts` login: on a revision mismatch, `noteOutdatedClient()`
decodes the rest of the login block (identical across revisions) for the username and keeps
`{revision, at}` in `World.outdatedClients` (30 min TTL); a correct-revision login clears it.
Management port `/outdated-client?username=` exposes it (port 8898, not published by fly).
Gateway `sdk_connect`: when the bot has no live client, it asks that endpoint and answers
`sdk_error` naming the fix — old SDK checkouts print gateway errors, so this is the one hint
that reaches people who haven't pulled. Public `/api/version` (`web/pages/api.ts`) returns
`{revision}`; the lite runner (`session.ts`) checks it first and throws `OutdatedClientError`
(a `LoginError` code 6) — runner/swarm exit **3** so supervisors stop restarting.
Verify: `curl localhost:8888/api/version` and `curl 'localhost:8898/outdated-client?username=x'`.
### Database
- [ ] **Bun sqlite dialect** — `src/db/dialect/BunSqliteDialect*.ts` (3 files) + runtime chooser
in `src/db/query.ts` (`typeof Bun !== 'undefined'` → bun:sqlite, else upstream's
`node:sqlite`). Upstream is node-primary; **Bun does not implement `node:sqlite`** —
without this the engine won't boot under bun.
- [ ] **SQLite WAL + busy_timeout** — `src/db/query.ts` runs
`journal_mode=WAL; synchronous=NORMAL; busy_timeout=10000` on every connection.
Each worker thread has its own connection; without WAL a logger write burst
(telemetry compaction) blocked login queries and took prod logins down after the
2026-08-03 deploy. Paired: LoggerServer first-compaction delay 15min
(past the reconnect storm) + 10ms pause between compaction groups, and the
gateway auth timeout is 15s (must outlast busy_timeout). WAL adds
`db.sqlite-wal/-shm`; file-copy backups must checkpoint first.
### Web layer (mostly rs-sdk-only files, but `src/web.ts` is a 3-line shim — on conflict keep the shim)
- [ ] **Upstream web server is Fastify since 289** (`src/web.ts`, `@fastify/*` deps). We keep the
Bun.serve shim and do NOT carry the fastify deps or `tsx` in `package.json`. Port anything
new from upstream's `web.ts` by hand — 289 added `OnDemand.onClientClosed(client)` to the WS
close handler, mirrored in `src/web/websocket.ts` (without it OnDemand's client map leaks).
- [ ] **`WSClientSocket.ts` keeps our API**: `new WSClientSocket()` + `init(socket, addr)` (the
socket only exists after Bun's `open`), per-tick output batching (`beginBatch`/`endBatch`,
`flush`, `discard`) and the **1s delayed close**. Upstream 289 moved to
`constructor(socket, addr)` and immediate close (its 289 client drains buffered bytes after
a remote close). `TcpClientSocket.close()` also keeps the 1s delay — reconnect closes the old
socket before ownership moves, and `test/fixtures/reconnect-lifecycle.ts` asserts that close
can't detach the replacement. On conflict keep ours.
- [ ] **`src/web/`** modular split: `websocket.ts` (`/gateway` WS proxy → gateway on :7780,
`isAgentProxy`), `pages/api.ts` (`/api/exportCollision` — must read the in-engine TS
routefinder, NOT the removed WASM; discovers mapsquares from maps **zip ∪ dir**;
`/api/screenshot`), `pages/client.ts` (serves `view/bot.ejs` at `/` and `/bot`),
`pages/hiscores.ts` + `src/web/hiscoresServer.ts` + `src/hiscores.ts` (custom hiscores;
**profile query param XSS-sanitized**), `pages/screenshots.ts`, `pages/static.ts`.
- [ ] **World feed** (`src/web/worldfeed.ts`, rs-sdk-only): read-only `/worldfeed` WebSocket
streaming live players/npcs for external 3D viewers (rs-world). Off unless `WORLD_FEED=true`
(`WORLD_FEED_TOKEN`, `WORLD_FEED_MAX_CLIENTS` in `Environment.ts`). Hooks: `isWorldFeed`
branches in `websocket.ts` upgrade/open/message/close, and `cycleWorldFeed()` in
`World.cycle()` between `processClientsOut()` and `processCleanup()` — it reads the per-tick
info masks, `walkDir`/`runDir`/`tele`/`jump` and `lastTickX/Z`, so it must stay before cleanup.
Verify: `grep -n "cycleWorldFeed()" src/engine/World.ts` and `bun test test/worldfeed.test.ts`.
- [ ] **`view/bot.ejs`** — the entire bot UI page (rs-sdk-only): reads `?bot=`/`?password=`
(auto-login), **writes both back to the URL on login/field-change**, cache-busted
`client.js?v=<%= cachebust %>` import, quick-login/create/skip-tutorial controls.
- [ ] **Login server** — `LoginServer.ts`: `sdk_auth` message handler (gateway auth path,
username normalized to match engine); `LoginThread.ts`: **no auto-grant of dev
staffmodlevel on non-production worlds** (public server safety).
### Gameplay / safety
- [ ] **XP curve** — `entity/Player.ts` `getExpByLevel` table: delta uses `level/10.0`
(custom curve), table stored in ×10 "fine" units (`Math.floor(acc/4) * 10`), L99 =
10,701,400. **Duplicated in webclient — keep in sync (see below).**
- [ ] **`PlayerLoading.ts`** — clamp loaded levels to base levels.
- [ ] **Anti-grief removals** — `MessagePrivateHandler.ts` / `ReportAbuseHandler.ts`: upstream's
automated 2-day bans REMOVED (bots trip them).
- [ ] **Random events toggle** — `ScriptOpcode.ts` + `DebugOps.ts`: `MAP_RANDOM_EVENTS` opcode
backed by `NODE_RANDOM_EVENTS` env (paired with content `engine.rs2`, see content section).
- [ ] **`[LOGOUT DEBUG]` instrumentation** — console.warn breadcrumbs in
`NetworkPlayer.ts`, `IdleTimerHandler.ts`, `ClientCheatHandler.ts`, `PlayerOps.ts`,
`World.ts` (and webclient `Client.ts`). Low-stakes but useful; fine to re-add lazily.
- [ ] **Lava Maze runite hiscores** — content `mining.rs2` (`get_ore_normal`) calls the
`runite_mined(loc_coord)` command (declared in `engine.rs2`) whenever it gives runite
ore; `RUNITE_MINED` opcode (10011) in `ScriptOpcode.ts`/`ScriptOpcodePointers.ts`, handler
in `DebugOps.ts` counts only the two Lava Maze rocks (0_3059_3885, 0_3060_3884, from
`m47_60.jm2`) → `World.notifyRuniteMined` → `runite_mine` relay cases in
`LoggerThread.ts`/`LoggerClient.ts`/`LoggerServer.ts`, `runite_mine` table (both prisma
schemas + `db/types.ts`), `/hiscores/runite` page in `web/pages/hiscores.ts`
(registered in `web/index.ts` + `web/hiscoresServer.ts`). Depends on the logger
pipeline (EASY_STARTUP or a standalone `bun run logger`) — events are silently
dropped without it. Replaced King of the Hill (migration `20260926000000_runite_mine`
drops `koth_capture`).
Verify: `grep -n "runite_mined" ../content/scripts/skill_mining/scripts/mining.rs2 && grep -n "RUNITE_MINED" src/engine/script/handlers/DebugOps.ts`
- [ ] **Equipment hiscores sprites** — `LoginServer.ts` `outfitAppearance()` stores the worn
outfit's resolved appearance slots (wearpos2/3 masking needs obj config) in
`hiscore_outfit.appearance` (migration `20260926010000_hiscore_outfit_appearance`) on every
outfit hiscore write; `/hiscores/outfit` shows a `/sprite/player` image for the top 10 and
`/sprite/item` icons (no in-browser viewer bundle). Sprites come from `web/sprites/`
(worker hosting webclient `src/viewer/ItemViewer.ts` `renderPlayerSprite`).
- [ ] **Hiscore playtime tiebreak** — `hiscore.playtime`/`hiscore_large.playtime` (migration
`20260208000000_hiscore_playtime_and_outfit`); boards rank level desc, playtime asc.
`LoginServer.ts` `updateHiscores()` stamps playtime only when a row's **level** changes
(XP-only changes keep it), so it means "playtime when this level was reached" — restamping
on every logout pushed maxed players down for continuing to play. Quest points (type 100,
`updateQuestHiscore()`) follows the same rule.
Verify: `grep -n "existing.level === totalLevel ? existing.playtime" src/server/login/LoginServer.ts`
- [ ] **Worker-thread crash hardening** — `src/server/InternalClient.ts` uses persistent
`.on('close'/'error')` handlers instead of `.once()` (a second ws error after a
successful open was an unhandled EventEmitter 'error' that killed the worker → every
later `postMessage` threw mid-cycle → whole world shut down; this was the "engine
dies ~40s into a bot session" local failure). `World.ts` constructor and `app.ts`
easyStartup block attach 'error'/'exit' listeners to every Worker so the dying
thread is named in the log.
- [ ] **Choice-dialog resume guard** — `handler/ResumePauseButtonHandler.ts` ignores a bare
RESUME_PAUSEBUTTON while `player.resumeButtons` is non-empty (a pending `p_choice`
would otherwise resolve from stale `last_com`, silently re-picking the player's last
clicked option — bots re-declined the Al Kharid toll forever);
`handler/IfButtonHandler.ts` clears `resumeButtons` when a registered option resumes
the script. Verify: `HEADLESS=true bun sdk/test/alkharid-gate-choice-resume.ts`.
### Grand Exchange (rs-sdk)
- [ ] **Physical market** — `src/engine/market/{MarketStore,MarketCatalog,GrandExchange}.ts`,
`GE_OPEN(coord)` opcode in `ScriptOpcode.ts` / `DebugOps.ts`, native `IfButtonHandler` and
`ResumePCountDialogHandler` hooks. Paired content: `engine.rs2` command, dedicated
`grand_exchange_booth`/`grand_exchange_teller` configs and triggers, `grand_exchange.if`,
append-only interface/loc/npc packs and `interface.order`. Map `m49_53.jm2`
reuses the west-wall bank table at 3180,3443 (original model, rotation and
collision) and places the teller at 3181,3445. Disabling GE restores the
original bank table. Ordinary bank booths remain banking-only.
`World.processPlayers` calls `tickExchange` to push changed offers; preserve this
with the visual offer form (there is no visible refresh button).
Repack with `BUILD_VERIFY=false` after custom content changes.
- [ ] **Thumbnail search** — shared `ItemSearch.ts` aliases/fuzzy ranking; native search
components with client codes 30400–30402, 30410–30421 and 30440–30451.
`MARKET_SEARCH` client opcode **243**, length -1, must match engine
`ClientGameProt`/repository/decoder/handler and webclient `ClientProt`.
Payload: `pjstr(query)` then `p2(item)` (0 filters; positive selects a shown
canonical ID). Max query 48 ASCII characters; server requires a physical
session and matching current query/page. `Client.ts` keyboard and click hooks
use `MarketSearchInput.ts`; lite component clicks resolve displayed IDs too.
Rebuild standard and bot clients. No new server-to-client packet.
Verify `item-search.test.ts`, `bot/MarketSearchInput.test.ts` and the integration.
- [ ] **Atomic recovery** — `Player.save()` checkpoints into the market ledger;
`PlayerLoading.load()` prefers that snapshot over stale login-service saves.
Placement/collection checkpoint all online players in the same SQLite transaction
to cover items handed between players since an autosave. Preserve both hooks.
`data/market.sqlite` (or `GE_DATABASE`) is authoritative durable data, including
player checkpoints; back it up with the world, never independently delete/restore it.
- [ ] **Market web and SDK** — `web/pages/market.ts` registered in `web/index.ts`,
`public/market.html`, `view/bot.ejs` Market link; `sdk/market.ts` and three
`BotSDK.getMarket*` methods. API is GET-only and exposes no player identities.
Verify: root `bun test server/engine/test/market.test.ts sdk/test/market-client.test.ts`,
then `bun run test:market:integration` with built content. Full design/operations:
`docs/grand-exchange.md`; API: `sdk/MARKET.md`.
### Custom content IDs
Upstream pack ids mirror each revision's cache: they don't grow within a revision (274's maxima were
unchanged across 77 upstream commits) but jump at every revision bump, and upstream also *names*
placeholder ids over time (`varp_357/358` became `boardgames_varbit3/4` in 289). Our configs either
sit in a **reserved range above any upstream revision** (never renumber) or at the pack tail
(renumber on every revision bump). The packers write gaps as empty entries.
Reserved (fixed since 289; upstream `377-wip` tops out at interface 18785 / varp 724):
- [ ] **Interfaces 40000+** — GE `grand_exchange` root **40000** … `grand_exchange_side:inv` **40495**
(`interface.pack` + same ids appended to `interface.order`). Free: the interface format lists
component ids explicitly, so the gap costs no bytes. Ids must stay below 65535 (the format's
root marker). Add new custom interfaces after 40495.
- [ ] **Varps 1000+** — `pvp_death_mark` **1000**. Saves store varps by id, so a fixed id means no
more save migrations. Cost: per-player `vars`/`varsString` arrays are sized to the max id.
`PlayerLoading.load` still moves a value found at **358** (its 274 id) to `pvp_death_mark`;
safe because 289's varp 358 is temp-scoped and never saved. Keep that shim until no pre-289
saves remain (prod saves + `market.sqlite` checkpoints).
- [ ] Numeric reserved ids outside content: `sdk/actions.ts` `NEVER_AUTO_CLOSE` (40000) and
`sdk/test/ge-actions.test.ts`. Engine code resolves GE components by name (`Component.getId`).
Tail-appended (renumber at each revision bump):
- [ ] `loc.pack` `grand_exchange_booth` **5116** (274: 4671). Not reserved high: the engine allocates a
`LocType` per id, so 30000 would mean ~25k empty objects in prod heap.
- [ ] `npc.pack` `grand_exchange_teller` **1596** (274: 1359). Can't go high: 289's NPC_INFO sends the
type in 11 bits (max 2047); 377 widens to 13 bits but has 3851 npcs, so it collides then anyway.
- [ ] `maps/m49_53.jm2` references both **numerically** (`0 44 51: 5116 10 1`, NPC section
`0 45 53: 1596`) — update with the packs. Neither id is persisted anywhere.
Upgrade hazards:
- [ ] **Asset debugnames get renamed upstream** (289 renamed ~3.5k models, plus idk/seq/npc/interface
names; ids stayed stable). Our configs reference names: `grand_exchange.loc` model
`furniture_banktable` (274: `model_loc_590`), `grand_exchange.npc` `man_*`/`chat_male_head_shorthair`
(274: `model_NNN_idk`). Map old→new through the id in `pack/model.pack`; the build fails loudly
on a missing name.
- [ ] `Player.save()` also checkpoints into `data/market.sqlite` `accounts`, and `load()` prefers that
row — a save-format experiment on a throwaway name leaves a row there; delete it after.
- [ ] `quest_mortton/.../razmire_keelgan.rs2` building store: our anti-buyback sell price **650**
combined with upstream 289's 4% haggle fix → `~openshop(razmirebuildingstore, 650, 1050, 40, ...)`.
### Assets
- [ ] **`FileStream.write`** — update `packed[archive][file]` after changed writes, so
same-pass version-list CRCs and `ondemand.zip` see the new map/model bytes.
Verify `bun test server/engine/test/file-stream.test.ts` from the root.
- [ ] `public/img/skill/*` (19 files), `public/img/*`, favicons, hiscores images —
restored after upstream website migrations deleted them. Verify pages render with images.
- [ ] `tools/pack/PackAll.ts` `packOnDemandZip()` — regenerates `data/pack/ondemand.zip`
(snapshot of cache idx1–4) at the end of `packAll()`. Existed upstream in the 254 era,
dropped in 274. Serves the hiscores ItemViewer at `/ondemand.zip`; without it, newly
packed item models render as blank hiscores icons. Verify: after `bun run build`,
`unzip -l data/pack/ondemand.zip` includes the highest model id in `content/pack/model.pack`.
---
## Webclient (`server/webclient/`)
### Lite client (wholly added — `src/lite/`, headless bot client; see `src/lite/README.md`)
- [ ] `src/lite/**` is rs-sdk-only and has no upstream counterpart — it should survive
a sync untouched. It **consumes** vendored modules though (`config/*Type`,
`dash3d/Client{Player,Npc,Obj}`, `dash3d/CollisionMap`, `io/{Packet,Isaac,JagFile,
ClientStream,ServerProt,ClientProt}`, `wordfilter/*`), so a decode change upstream
lands in both clients — which is the point.
Verify: `bun src/lite/bench.ts 2` logs in and reports live sessions.
- [ ] **Lite protocol decoder duplicates `Client.ts` packet reads** (`src/lite/protocol/incoming.ts`,
`entities.ts`, `zone.ts`) and the login handshake (`src/lite/net/GameConnection.ts`
`CLIENT_VERSION`). Opcode renumbering flows through the shared `ServerProt`/`ClientProt`
enums, but **layout changes don't**: diff upstream's `Client.ts` read paths every upgrade.
289 changed `UPDATE_INV_FULL` size `g1`→`g2` and `UPDATE_INV_PARTIAL` slot `g1`→`gsmart`
(ported). A mismatch desyncs the stream silently or kicks the bot.
- [ ] **`src/io/ClientStream.ts` `isClosed` getter** — 3 added lines exposing
`dummy || remoteClosed`. The browser client learns a socket is dead by reading from
it and taking the throw; the lite session loop only reads when `available > 0`,
which a remotely-closed stream reports as 0 *forever* — so without this the loop
spins on a dead socket and the session never ends. Consumed by
`lite/net/GameConnection.isClosed` → `LiteClient.isInGame()`.
Keep `dummy || remoteClosed` even though 289's `available` now reports buffered bytes after
a remote close: a partial trailing packet would leave `available > 0` forever.
Verify: `grep -n "get isClosed" src/io/ClientStream.ts src/lite/net/GameConnection.ts`
- [ ] **`src/client/LoopCycle.ts` + the `Client.loopCycle` accessor pair** — `loopCycle`
moved out of Client into a one-field module, and `Client.ts` now exposes it as
`static get/set loopCycle` over that box. Reason: `ClientPlayer.ts` read
`Client.loopCycle` for one line in `getSequencedModel()`, and that import drags the
whole 14k-line Client (plus Pix3D, MapView, WebAudio, localStorage) into anything
touching a ClientPlayer — 212MB of imports for the headless client, vs 21MB after.
The box stays browser-only state: the lite client keeps a `cycle` counter per
`LiteClient`, since one counter shared by N bots in a process runs N× fast.
On conflict: keep the accessor, re-point `ClientPlayer.ts` at `LoopCycle.value`.
Verify: `grep -n "LoopCycle" src/dash3d/ClientPlayer.ts src/client/Client.ts`
**⚠ `ClientPlayer.ts` must NOT import `#/client/Client.js`.** An upstream sync that
restores that import silently re-bloats the lite client; it still runs, so nothing
fails loudly. Cheap check:
`bun -e 'import("./src/dash3d/ClientPlayer.js")'` must not need a DOM shim.
- [ ] **400-char chat (webclient half)** — `wordfilter/WordPack.ts` internal clamps raised to
`MAX_CHARS = 512` (safety bound above the ~509 wire ceiling; the real cap is enforced
at call sites from the server-configured value) plus the same 240-byte
`truncateToByteBudget` as the engine copy. `lite/LiteClient.ts` default
`maxMessageLength` is 400 (lite has no config-injection channel — keep in sync with
the engine `WorldConfig` default). `lite/runner.ts` honors a `GATEWAY_URL` override
(bot.env or process env) because SERVER doubles as game origin + gateway address and
that breaks for `localhost:8888`.
Verify: `grep -n "MAX_CHARS = 512" src/wordfilter/WordPack.ts && grep -n "?? 400" src/lite/LiteClient.ts`
### Bot bridge (wholly added — `src/bot/`, 8 files + `src/client/BotClient.ts`, `src/viewer/ItemViewer.ts`)
- [ ] `StateCollector.ts`, `BotOverlay.ts`, `ActionExecutor.ts`, `GatewayConnection.ts`,
`OverlayUI.ts`, `formatters.ts`, `types.ts`, `index.ts`.
Note: **gateway state messages come from `BotOverlay.sendState()`** (includes
`allComponents`/`componentId`), not `StateCollector.collectDialogState` (basic fallback).
`GatewayConnection` reads `?bot=`/`?password=` **at page load** for gateway registration.
- [ ] **`src/dash3d/LoopCycle.ts`** (rs-sdk-only) — the frame counter split out of Client;
`Client.loopCycle` is now a static getter/setter over it and
`ClientPlayer.ts` reads `LoopCycle.value` instead of importing Client. Required so
the viewer bundle (which imports ClientPlayer for character sprites) doesn't
drag the whole game client (and its node-only `open` dep) in and fail to build.
### Client.ts bot SDK surface (~1,450 added lines inside upstream's `src/client/Client.ts`)
- [ ] Bot methods: `autoLogin`, `getDialogOptions`/`getDialogText`/`getChatInterface`/
`captureDialogToHistory`/`debugDialogComponents`, `findNpcByName`, `talkToNpc`,
`interactNpc/Loc/Player`, `acceptCharacterDesign` (**must send IDK_SAVEDESIGN AND the
CC_ACCEPT_DESIGN IF_BUTTON**), `setTargetedFramerate`, etc.
- [ ] **Walk-before-op**: `interactLoc`, `talkToNpc`, `interactNpc`, `interactPlayer` ALL call
`tryMove(..., type=2)` before writing OPLOC/OPNPC/OPPLAYER. 274's
`clientRoutefinder=true` means the SERVER DOES NOT PATHFIND to interaction targets —
a missing tryMove = "I can't reach that!" from 2+ tiles.
- [ ] **`MESSAGE_PUBLIC` receive branch** (pairs with engine broadcast): `ptype ===
ServerProt.MESSAGE_PUBLIC` → g8 userhash + `WordPack.unpack(psize - 8)` →
`addChat(2, ...)`. Plus `src/io/ServerProt.ts`: `MESSAGE_PUBLIC = 255` and size-table
entry `-1` at index 255.
Verify: `grep -n "ServerProt.MESSAGE_PUBLIC" src/client/Client.ts` (a receive `if`, not
just the enum).
- [ ] **`mapFlagUnsetCount`** — the `UNSET_MAP_FLAG` receive branch increments a counter
beside `this.minimapFlagX = 0`. It is the only wire evidence that the server refused
an op (every `Op*Handler` refusal answers with this packet and nothing else), and
`bot/StateCollector.collectOpFeedback` turns it into `state.opFeedback`. Upstream
just clears the flag, so a sync silently reverts it — and nothing fails, the signal
merely goes quiet and bots go back to waiting out timeouts.
**Pairs with `src/lite/protocol/incoming.ts`**, which must increment the same field.
Verify: `grep -n "mapFlagUnsetCount" src/client/Client.ts src/lite/protocol/incoming.ts`
(two files, one increment each), plus `bun test src/bot/StateCollector.test.ts`.
- [ ] **XP table** — `Client.ts` `levelExperience`: same `level/10.0` curve but **NO ×10**
(client receives real xp; engine stores fine xp). Verify both formulas side-by-side after
any sync touching `Player.ts` or `Client.ts`.
- [ ] **AFK logout extended** to 10 minutes (upstream 90s logs idle bots out).
- [ ] **Renamed-field adaptations** (274): `chatModalId`, `redrawChat`, `sideIcon`,
`activeIcon`, `redrawSide`, `redrawIcons`, static `Client.loopCycle` — the bot bridge
accesses some via `as any`, so **`bunx tsc --noEmit` does NOT catch all of these**;
grep-audit `(client as any).X` accesses after a sync.
### Build / shell
- [ ] **`bundle.ts`** — `BUILD_MODE` standard/bot/both; **terser property mangling OFF**
(mangling breaks bot.ejs/Puppeteer accessing client members by name).
- [ ] **`GameShell.ts`** — `deltime = 14` (≈30% faster client loop).
- [ ] **`MapView.ts`** — live player-position tracking (`playerPositions`,
`shouldDrawPlayers`) for the `/mapview/` page; pairs with engine `/playerpositions`.
Also replaces upstream's three-area map (`reloadMain/Dungeon/Extra`) with one unified map:
`MAP_BANDS` stacks overworld (raw mapsquare rows 42–62), underground (142–161 → 63–82) and
the high-z misc areas (69–77 → 83–91) without overlap; columns 29–56; bounds include one
padding square per side because the loaders skip edge squares. On conflict keep ours. After
a content sync, list `content/maps/m*_*.jm2` and confirm every square falls in a band and
the column range (289 added Ape Atoll at rows 42–43 and its dungeon at 142–143).
- [ ] `src/3rdparty/tinymidipcm.js` tweak; `package.json` (bun scripts, deps).
- [ ] **Filename casing**: `src/io/JagFile.ts` (capital F) in webclient vs `src/io/Jagfile.ts`
in engine. macOS hides case-only renames from git — after a sync run:
`comm -23 <(git ls-files | sort) <(find . -type f -not -path './.git/*' | sed 's|^\./||' | sort)`
(the 2 submodule gitlinks are expected hits).
---
## Content (`server/content/`)
- [ ] **`scripts/engine.rs2`** — `[command,map_random_events]` declaration (pairs with engine
`MAP_RANDOM_EVENTS` opcode — both sides or scripts fail to compile).
- [ ] **`scripts/login_logout/login.rs2`** — random-event timer gated on `map_random_events`.
- [ ] **`scripts/macro events/scripts/macro_events.rs2`** — macro events disabled when off.
- [ ] **`scripts/shop/configs/shop.varp`** — `transmit=yes` on shop varps (bot shop state).
- [ ] **`scripts/minigames/game_managing_miscellania/scripts/managing_miscellania.rs2`** — coffers
deposit clamps to `min(requested, coffer space, current coins held)` after the count dialog;
withdraw rejects non-positive counts and checks the current coffer balance and inventory
capacity before transferring money. Report and initial fix by kadeski ([PR #73](https://github.com/MaxBittker/rs-sdk/pull/73)).
Reported upstream to LostCity; drop this patch once their 289 branch carries a fix.
Verify: from the repo root, `bun test server/engine/test/miscellania-coffers.test.ts` (requires built engine content).
- [x] **README art** — moved out of the vendored tree to `docs/media/` (promo.gif, discord.svg,
hiscores.svg, task_length.svg); nothing in `content/title/` is locally modified anymore.
- [ ] Smithing arrowheads + telegrab + nails fixes live in content history; they're additive
and survive rebases, but re-run a smoke test if smithing/magic behaves oddly.
---
## Cross-boundary invariants (how things actually break)
These are the rules derived from every severed-wire bug found so far:
1. **A custom `ServerGameProt` packet MUST ship with its webclient `ptype` receive branch in
the same commit.** Unhandled game packets hit the client's T1 path → **logout**. (Global
chat shipped engine-only in `47af85ff7` and silently kicked every out-of-range player on
chat for a month.) Gateway messages / bot actions / state fields degrade silently;
game packets do not.
2. **XP curve is duplicated** engine `Player.ts` ↔ webclient `Client.ts` (units differ ×10).
Change one ⇒ change both.
3. **`MAP_RANDOM_EVENTS`** is duplicated engine opcode ↔ content rs2 command.
4. **BotAction unions are duplicated** `sdk/types.ts` ↔ `server/webclient/src/bot/types.ts`,
and every action needs an `ActionExecutor` case.
5. **Tests must not hardcode opcode numbers** — use `ClientProt.*`/`ServerProt.*` (289 broke 11
literal-opcode assertions in `bot/ClientInteraction.test.ts`; hand-built packets in
`lite/*.test.ts` also encode packet layouts).
6. **tsc is necessary but not sufficient**: run `bunx tsc --noEmit` in BOTH engine and
webclient after every sync (esbuild bundles despite TS errors), but `as any` client-field
accesses and bot.ejs `clientInstance.*` references are invisible to it — grep-audit those.
## Post-sync verification (5 minutes)
```bash
# typecheck both (esbuild hides TS errors)
(cd server/engine && bunx tsc --noEmit) && (cd server/webclient && bunx tsc --noEmit)
# case-only rename check (macOS hides these from git)
comm -23 <(git ls-files | sort) <(find . -type f -not -path './.git/*' | sed 's|^\./||' | sort)
# custom packet pairing
grep -n "MESSAGE_PUBLIC = new ServerGameProt(255" server/engine/src/network/game/server/ServerGameProt.ts
grep -n "ptype === ServerProt.MESSAGE_PUBLIC" server/webclient/src/client/Client.ts
# XP curve parity (compare by eye: same formula, engine has *10, client doesn't)
grep -A2 "Math.pow(2.0, level / 10.0)" server/engine/src/engine/entity/Player.ts server/webclient/src/client/Client.ts
# boot + live endpoints after deploy
curl -so /dev/null -w "%{http_code}\n" https://rs-sdk-demo.fly.dev/{playercount,hiscores,mapview/}
# custom IDs: reserved ranges still above upstream, tail-appended ones past upstream's max
grep -n "^40000=\|^1000=" server/content/pack/{interface,varp}.pack
grep -n "grand_exchange_booth\|grand_exchange_teller" server/content/pack/{loc,npc}.pack
tail -1 server/content/pack/{loc,npc}.pack
# end-to-end: login a bot, chop a tree 3+ tiles away (exercises walk-before-op),
# have a second distant bot chat (exercises MESSAGE_PUBLIC both directions),
# deposit at a bank + open the GE (exercises inventory packet layouts + custom ids)
```
If content map files changed: regenerate `sdk/collision-data.json` from the MEMBERS prod server:
`curl https://rs-sdk-demo.fly.dev/api/exportCollision > sdk/collision-data.json`