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.
32 KiB
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.tsbroadcasts public chat to all players outside the 14-tile overhead range via customMESSAGE_PUBLICpacket (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 webclientServerProt/ClientProt), and thatServerProtSizes[255] === -1.
Config / environment
Environment.ts— flat back-compat aliases over 274's nestedWorldConfig, plusmigrateFromLegacyEnv(loadWorldConfig(), process.env)overlay so fly.io[env]vars win overworld.json. AlsoNODE_WS_ONDEMANDdefaults true (274 client streams assets over the game WS; false ⇒ stalls at ~60% "Connecting to update server").WorldConfig.ts— defaultweb.port = 8888on all platforms;xpRate = 25.- 400-char chat —
WorldConfig.tsdefaultnode.maxMessageLength = 400(classic 80). Paired pieces, all required:wordenc/WordPack.tscaps followEnvironment.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.tsusePacket.alloc(1)notalloc(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.tspackedNibbles). 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_CONNECTION5m /TIMEOUT_NO_RESPONSE10m, gated byNODE_DEBUG_SOCKET).World.tstick drift cap —cycle()clampsnextTickto at most 2 ticks of backlog before computingdrift, 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.tslogin: on a revision mismatch,noteOutdatedClient()decodes the rest of the login block (identical across revisions) for the username and keeps{revision, at}inWorld.outdatedClients(30 min TTL); a correct-revision login clears it. Management port/outdated-client?username=exposes it (port 8898, not published by fly). Gatewaysdk_connect: when the bot has no live client, it asks that endpoint and answerssdk_errornaming 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 throwsOutdatedClientError(aLoginErrorcode 6) — runner/swarm exit 3 so supervisors stop restarting. Verify:curl localhost:8888/api/versionandcurl 'localhost:8898/outdated-client?username=x'.
Database
- Bun sqlite dialect —
src/db/dialect/BunSqliteDialect*.ts(3 files) + runtime chooser insrc/db/query.ts(typeof Bun !== 'undefined'→ bun:sqlite, else upstream'snode:sqlite). Upstream is node-primary; Bun does not implementnode:sqlite— without this the engine won't boot under bun. - SQLite WAL + busy_timeout —
src/db/query.tsrunsjournal_mode=WAL; synchronous=NORMAL; busy_timeout=10000on 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 addsdb.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 ortsxinpackage.json. Port anything new from upstream'sweb.tsby hand — 289 addedOnDemand.onClientClosed(client)to the WS close handler, mirrored insrc/web/websocket.ts(without it OnDemand's client map leaks). WSClientSocket.tskeeps our API:new WSClientSocket()+init(socket, addr)(the socket only exists after Bun'sopen), per-tick output batching (beginBatch/endBatch,flush,discard) and the 1s delayed close. Upstream 289 moved toconstructor(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, andtest/fixtures/reconnect-lifecycle.tsasserts that close can't detach the replacement. On conflict keep ours.src/web/modular split:websocket.ts(/gatewayWS 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(servesview/bot.ejsat/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/worldfeedWebSocket streaming live players/npcs for external 3D viewers (rs-world). Off unlessWORLD_FEED=true(WORLD_FEED_TOKEN,WORLD_FEED_MAX_CLIENTSinEnvironment.ts). Hooks:isWorldFeedbranches inwebsocket.tsupgrade/open/message/close, andcycleWorldFeed()inWorld.cycle()betweenprocessClientsOut()andprocessCleanup()— it reads the per-tick info masks,walkDir/runDir/tele/jumpandlastTickX/Z, so it must stay before cleanup. Verify:grep -n "cycleWorldFeed()" src/engine/World.tsandbun 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-bustedclient.js?v=<%= cachebust %>import, quick-login/create/skip-tutorial controls.- Login server —
LoginServer.ts:sdk_authmessage 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.tsgetExpByLeveltable: delta useslevel/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_EVENTSopcode backed byNODE_RANDOM_EVENTSenv (paired with contentengine.rs2, see content section). [LOGOUT DEBUG]instrumentation — console.warn breadcrumbs inNetworkPlayer.ts,IdleTimerHandler.ts,ClientCheatHandler.ts,PlayerOps.ts,World.ts(and webclientClient.ts). Low-stakes but useful; fine to re-add lazily.- Lava Maze runite hiscores — content
mining.rs2(get_ore_normal) calls therunite_mined(loc_coord)command (declared inengine.rs2) whenever it gives runite ore;RUNITE_MINEDopcode (10011) inScriptOpcode.ts/ScriptOpcodePointers.ts, handler inDebugOps.tscounts only the two Lava Maze rocks (0_3059_3885, 0_3060_3884, fromm47_60.jm2) →World.notifyRuniteMined→runite_minerelay cases inLoggerThread.ts/LoggerClient.ts/LoggerServer.ts,runite_minetable (both prisma schemas +db/types.ts),/hiscores/runitepage inweb/pages/hiscores.ts(registered inweb/index.ts+web/hiscoresServer.ts). Depends on the logger pipeline (EASY_STARTUP or a standalonebun run logger) — events are silently dropped without it. Replaced King of the Hill (migration20260926000000_runite_minedropskoth_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.tsoutfitAppearance()stores the worn outfit's resolved appearance slots (wearpos2/3 masking needs obj config) inhiscore_outfit.appearance(migration20260926010000_hiscore_outfit_appearance) on every outfit hiscore write;/hiscores/outfitshows a/sprite/playerimage for the top 10 and/sprite/itemicons (no in-browser viewer bundle). Sprites come fromweb/sprites/(worker hosting webclientsrc/viewer/ItemViewer.tsrenderPlayerSprite). - Hiscore playtime tiebreak —
hiscore.playtime/hiscore_large.playtime(migration20260208000000_hiscore_playtime_and_outfit); boards rank level desc, playtime asc.LoginServer.tsupdateHiscores()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.tsuses 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 laterpostMessagethrew mid-cycle → whole world shut down; this was the "engine dies ~40s into a bot session" local failure).World.tsconstructor andapp.tseasyStartup block attach 'error'/'exit' listeners to every Worker so the dying thread is named in the log. - Choice-dialog resume guard —
handler/ResumePauseButtonHandler.tsignores a bare RESUME_PAUSEBUTTON whileplayer.resumeButtonsis non-empty (a pendingp_choicewould otherwise resolve from stalelast_com, silently re-picking the player's last clicked option — bots re-declined the Al Kharid toll forever);handler/IfButtonHandler.tsclearsresumeButtonswhen 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 inScriptOpcode.ts/DebugOps.ts, nativeIfButtonHandlerandResumePCountDialogHandlerhooks. Paired content:engine.rs2command, dedicatedgrand_exchange_booth/grand_exchange_tellerconfigs and triggers,grand_exchange.if, append-only interface/loc/npc packs andinterface.order. Mapm49_53.jm2reuses 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.processPlayerscallstickExchangeto push changed offers; preserve this with the visual offer form (there is no visible refresh button). Repack withBUILD_VERIFY=falseafter custom content changes. - Thumbnail search — shared
ItemSearch.tsaliases/fuzzy ranking; native search components with client codes 30400–30402, 30410–30421 and 30440–30451.MARKET_SEARCHclient opcode 243, length -1, must match engineClientGameProt/repository/decoder/handler and webclientClientProt. Payload:pjstr(query)thenp2(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.tskeyboard and click hooks useMarketSearchInput.ts; lite component clicks resolve displayed IDs too. Rebuild standard and bot clients. No new server-to-client packet. Verifyitem-search.test.ts,bot/MarketSearchInput.test.tsand 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(orGE_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.tsregistered inweb/index.ts,public/market.html,view/bot.ejsMarket link;sdk/market.tsand threeBotSDK.getMarket*methods. API is GET-only and exposes no player identities. Verify: rootbun test server/engine/test/market.test.ts sdk/test/market-client.test.ts, thenbun run test:market:integrationwith 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_exchangeroot 40000 …grand_exchange_side:inv40495 (interface.pack+ same ids appended tointerface.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_mark1000. Saves store varps by id, so a fixed id means no more save migrations. Cost: per-playervars/varsStringarrays are sized to the max id.PlayerLoading.loadstill moves a value found at 358 (its 274 id) topvp_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.sqlitecheckpoints). - Numeric reserved ids outside content:
sdk/actions.tsNEVER_AUTO_CLOSE(40000) andsdk/test/ge-actions.test.ts. Engine code resolves GE components by name (Component.getId).
Tail-appended (renumber at each revision bump):
loc.packgrand_exchange_booth5116 (274: 4671). Not reserved high: the engine allocates aLocTypeper id, so 30000 would mean ~25k empty objects in prod heap.npc.packgrand_exchange_teller1596 (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.jm2references both numerically (0 44 51: 5116 10 1, NPC section0 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.locmodelfurniture_banktable(274:model_loc_590),grand_exchange.npcman_*/chat_male_head_shorthair(274:model_NNN_idk). Map old→new through the id inpack/model.pack; the build fails loudly on a missing name. Player.save()also checkpoints intodata/market.sqliteaccounts, andload()prefers that row — a save-format experiment on a throwaway name leaves a row there; delete it after.quest_mortton/.../razmire_keelgan.rs2building store: our anti-buyback sell price 650 combined with upstream 289's 4% haggle fix →~openshop(razmirebuildingstore, 650, 1050, 40, ...).
Assets
FileStream.write— updatepacked[archive][file]after changed writes, so same-pass version-list CRCs andondemand.zipsee the new map/model bytes. Verifybun test server/engine/test/file-stream.test.tsfrom 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.tspackOnDemandZip()— regeneratesdata/pack/ondemand.zip(snapshot of cache idx1–4) at the end ofpackAll(). 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: afterbun run build,unzip -l data/pack/ondemand.zipincludes the highest model id incontent/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 2logs in and reports live sessions. -
Lite protocol decoder duplicates
Client.tspacket reads (src/lite/protocol/incoming.ts,entities.ts,zone.ts) and the login handshake (src/lite/net/GameConnection.tsCLIENT_VERSION). Opcode renumbering flows through the sharedServerProt/ClientProtenums, but layout changes don't: diff upstream'sClient.tsread paths every upgrade. 289 changedUPDATE_INV_FULLsizeg1→g2andUPDATE_INV_PARTIALslotg1→gsmart(ported). A mismatch desyncs the stream silently or kicks the bot. -
src/io/ClientStream.tsisClosedgetter — 3 added lines exposingdummy || remoteClosed. The browser client learns a socket is dead by reading from it and taking the throw; the lite session loop only reads whenavailable > 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 bylite/net/GameConnection.isClosed→LiteClient.isInGame(). Keepdummy || remoteClosedeven though 289'savailablenow reports buffered bytes after a remote close: a partial trailing packet would leaveavailable > 0forever. Verify:grep -n "get isClosed" src/io/ClientStream.ts src/lite/net/GameConnection.ts -
src/client/LoopCycle.ts+ theClient.loopCycleaccessor pair —loopCyclemoved out of Client into a one-field module, andClient.tsnow exposes it asstatic get/set loopCycleover that box. Reason:ClientPlayer.tsreadClient.loopCyclefor one line ingetSequencedModel(), 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 acyclecounter perLiteClient, since one counter shared by N bots in a process runs N× fast. On conflict: keep the accessor, re-pointClientPlayer.tsatLoopCycle.value. Verify:grep -n "LoopCycle" src/dash3d/ClientPlayer.ts src/client/Client.ts⚠ClientPlayer.tsmust 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.tsinternal clamps raised toMAX_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-bytetruncateToByteBudgetas the engine copy.lite/LiteClient.tsdefaultmaxMessageLengthis 400 (lite has no config-injection channel — keep in sync with the engineWorldConfigdefault).lite/runner.tshonors aGATEWAY_URLoverride (bot.env or process env) because SERVER doubles as game origin + gateway address and that breaks forlocalhost: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 fromBotOverlay.sendState()(includesallComponents/componentId), notStateCollector.collectDialogState(basic fallback).GatewayConnectionreads?bot=/?password=at page load for gateway registration. -
src/dash3d/LoopCycle.ts(rs-sdk-only) — the frame counter split out of Client;Client.loopCycleis now a static getter/setter over it andClientPlayer.tsreadsLoopCycle.valueinstead of importing Client. Required so the viewer bundle (which imports ClientPlayer for character sprites) doesn't drag the whole game client (and its node-onlyopendep) 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,interactPlayerALL calltryMove(..., type=2)before writing OPLOC/OPNPC/OPPLAYER. 274'sclientRoutefinder=truemeans the SERVER DOES NOT PATHFIND to interaction targets — a missing tryMove = "I can't reach that!" from 2+ tiles. MESSAGE_PUBLICreceive branch (pairs with engine broadcast):ptype === ServerProt.MESSAGE_PUBLIC→ g8 userhash +WordPack.unpack(psize - 8)→addChat(2, ...). Plussrc/io/ServerProt.ts:MESSAGE_PUBLIC = 255and size-table entry-1at index 255. Verify:grep -n "ServerProt.MESSAGE_PUBLIC" src/client/Client.ts(a receiveif, not just the enum).mapFlagUnsetCount— theUNSET_MAP_FLAGreceive branch increments a counter besidethis.minimapFlagX = 0. It is the only wire evidence that the server refused an op (everyOp*Handlerrefusal answers with this packet and nothing else), andbot/StateCollector.collectOpFeedbackturns it intostate.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 withsrc/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), plusbun test src/bot/StateCollector.test.ts.- XP table —
Client.tslevelExperience: samelevel/10.0curve but NO ×10 (client receives real xp; engine stores fine xp). Verify both formulas side-by-side after any sync touchingPlayer.tsorClient.ts. - AFK logout extended to 10 minutes (upstream 90s logs idle bots out).
- Renamed-field adaptations (274):
chatModalId,redrawChat,sideIcon,activeIcon,redrawSide,redrawIcons, staticClient.loopCycle— the bot bridge accesses some viaas any, sobunx tsc --noEmitdoes NOT catch all of these; grep-audit(client as any).Xaccesses after a sync.
Build / shell
bundle.ts—BUILD_MODEstandard/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_BANDSstacks 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, listcontent/maps/m*_*.jm2and 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.jstweak;package.json(bun scripts, deps).- Filename casing:
src/io/JagFile.ts(capital F) in webclient vssrc/io/Jagfile.tsin 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 engineMAP_RANDOM_EVENTSopcode — both sides or scripts fail to compile).scripts/login_logout/login.rs2— random-event timer gated onmap_random_events.scripts/macro events/scripts/macro_events.rs2— macro events disabled when off.scripts/shop/configs/shop.varp—transmit=yeson shop varps (bot shop state).scripts/minigames/game_managing_miscellania/scripts/managing_miscellania.rs2— coffers deposit clamps tomin(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). 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).- README art — moved out of the vendored tree to
docs/media/(promo.gif, discord.svg, hiscores.svg, task_length.svg); nothing incontent/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:
- A custom
ServerGameProtpacket MUST ship with its webclientptypereceive branch in the same commit. Unhandled game packets hit the client's T1 path → logout. (Global chat shipped engine-only in47af85ff7and silently kicked every out-of-range player on chat for a month.) Gateway messages / bot actions / state fields degrade silently; game packets do not. - XP curve is duplicated engine
Player.ts↔ webclientClient.ts(units differ ×10). Change one ⇒ change both. MAP_RANDOM_EVENTSis duplicated engine opcode ↔ content rs2 command.- BotAction unions are duplicated
sdk/types.ts↔server/webclient/src/bot/types.ts, and every action needs anActionExecutorcase. - Tests must not hardcode opcode numbers — use
ClientProt.*/ServerProt.*(289 broke 11 literal-opcode assertions inbot/ClientInteraction.test.ts; hand-built packets inlite/*.test.tsalso encode packet layouts). - tsc is necessary but not sufficient: run
bunx tsc --noEmitin BOTH engine and webclient after every sync (esbuild bundles despite TS errors), butas anyclient-field accesses and bot.ejsclientInstance.*references are invisible to it — grep-audit those.
Post-sync verification (5 minutes)
# 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