# Decisions > **Scope:** The rationale behind key design decisions. For the system's > architecture, see [`architecture.md`](./architecture.md). For the concrete > build plan, see [`implementation-plan.md`](./implementation-plan.md). > > Each entry records the decision, the context, and the alternatives considered. > New entries are appended; existing entries are updated only to correct facts, > not to rewrite history. ## D1 — Client-only, no caching **Decision:** The system runs on the client only and has no caching layer. **Context:** The tool is a personal, single-user utility. There is no shared state to protect and no multi-instance deployment. **Alternatives considered:** In-memory TTL caching; Redis-backed caching. Both rejected as unnecessary complexity for a single client. ## D2 — Separate `packages/extract` from `packages/tts` **Decision:** Analysis lives in `packages/extract`; fetching lives in `packages/tts`. They are separate packages with a clean fetch-vs-analyze boundary. **Context:** The user wants to analyze saves and extract objects for a future frontend. Keeping analysis separate lets it be reused independently of the fetcher. **Alternatives considered:** Folding analysis into `packages/tts`. Rejected — couples fetch and analysis and would force the frontend to depend on the fetcher. ## D3 — Traversal lives in `packages/extract` **Decision:** `traverseMod` / `markParent` move from the fetcher into `packages/extract`. **Context:** Traversal is analysis, not fetching. `packages/tts` should be fetch-only. **Alternatives considered:** Keeping traversal in `packages/tts`. Rejected — blurs the fetch/analyze boundary. ## D4 — `packages/extract` is isomorphic with zero runtime deps **Decision:** `packages/extract` runs in browser and Node, using only `fetch`, `Blob`, and typed arrays. It has no external runtime dependencies. **Context:** The frontend (deferred) will consume it directly, so it must be portable. Node 18+ provides global `fetch` and `Blob`. **Alternatives considered:** Using `Buffer` and Node-only packages. Rejected — breaks browser use. ## D5 — No backend traversal endpoints **Decision:** The proxy exposes search and fetch only. Traversal is not exposed as HTTP endpoints. **Context:** The frontend will use `packages/extract` directly. Exposing traversal on the backend would duplicate that logic and add endpoints with no current consumer. **Alternatives considered:** Adding `/items/:id/objects` and similar endpoints. Deferred until a consumer exists. ## D6 — Search scrapes the Workshop browse page **Decision:** Search is implemented by scraping `steamcommunity.com/workshop/browse/?appid=286160` with `cheerio`. **Context:** Steam has no official Web API for searching the Workshop; the API only fetches details for known IDs. **Alternatives considered:** None viable — there is no official search API. Accepted risk: scraping is fragile and may break if Steam changes its HTML. ## D7 — `bson` npm package replaces the browser `BSON` global **Decision:** The existing scraper's `BSON.deserialize` global is replaced with the `bson` npm package. **Context:** The scraper was written for the browser; the proxy runs on Node. **Alternatives considered:** Keeping a browser-only global. Rejected — not available in Node. ## D8 — Download output type is `Blob` **Decision:** `packages/extract`'s `downloadAsset` / `downloadAll` return `Blob`. **Context:** The primary consumer is a frontend that renders assets in `` / `` elements. **Alternatives considered:** Raw `ArrayBuffer`. Rejected — less convenient for frontend rendering. ## D9 — Frontend stack: React + React Router + Tailwind v4 + Zustand **Decision:** The frontend (`apps/web`) uses React with React Router for routing, Tailwind CSS v4 for styling, and Zustand for state, built with Vite. **Context:** The frontend consumes the proxy API for search/fetch and `packages/extract` directly for analysis. React is the de-facto standard for this kind of tool; React Router provides declarative routes (`/` and `/mod/:id`); Tailwind v4 is the current major version and integrates via `@tailwindcss/vite`; Zustand is a minimal, unopinionated store that fits the small amount of client state (search + mod). **Alternatives considered:** Next.js (heavier than needed for a client-only tool); Redux Toolkit (more boilerplate than warranted); CSS Modules (no utility styling). ## D10 — Object inspection via a viewer registry **Decision:** The mod page shows a containment-tree sidebar (mirroring how TTS nests objects) and an inspector pane. The inspector resolves a per-class viewer from a registry (`registerViewer` / `resolveViewer`), falling back to a `DefaultViewer` that renders an object's raw fields. **Context:** The user wants to visualize objects in a save. A flat list of all objects and assets is hard to navigate, so the UI was reworked around a tree and a selected-object inspector. Custom viewers per object class (`Card`, `Bag`, ...) are expected later; a registry keeps that extension point explicit without coupling the page to any one viewer. **Alternatives considered:** A single monolithic inspector component. Rejected — would grow unboundedly as per-class viewers are added; the registry keeps each viewer isolated and swappable. ## D11 — 3D viewers on the r3f/drei/postprocessing stack **Decision:** Per-class 3D viewers for tiles, tokens, cards, and custom models are built on `@react-three/fiber`, `@react-three/drei`, and `@react-three/postprocessing`, registered through the existing viewer registry and lazy-loaded so the three.js stack is code-split out of the main bundle. **Context:** The user wants to visualize objects in a save in 3D. The viewer registry (D10) already provides the extension point. Custom models load from `CustomMesh.MeshURL` (GLTF/OBJ/FBX); tiles/tokens/cards use their `CustomImage`/`CustomDeck` textures. Unity asset bundles are out of scope. **Alternatives considered:** A single monolithic 3D inspector; a WebGL library other than three.js. Rejected — the registry keeps viewers isolated, and r3f is the de-facto React/three.js integration. ## D12 — CORS-safe asset proxy **Decision:** The proxy exposes `GET /asset?url=...`, which fetches an external asset and streams it back with a `Content-Type` header. Only `http(s)` URLs are allowed. **Context:** Workshop asset hosts (steamusercontent.com, etc.) often omit CORS headers, which would block three.js `TextureLoader` / `GLTFLoader` in the browser. Routing asset fetches through the proxy makes them loadable. **Alternatives considered:** Loading assets directly in the browser. Rejected — CORS failures on common Workshop hosts would break the viewers. ## D13 — Server-side image tracing to a mesh-ready shape **Decision:** The proxy exposes `GET /trace?url=...`, which fetches an image, traces it into a vector shape, and returns the result BSON-encoded. Tracing is configurable: `mode` (`alpha` default, `bw`, `color`) selects how the region is derived, `format` (`shape` default, `svg`) selects the response, and `offset` (optional, in pixels) insets (negative) or outsets (positive) the resulting shape — used by the token viewer to shave the anti-aliased fringe off a traced silhouette. The `shape` format returns a parsed `{ outline, holes }` polygon matching `@tts/mesh`'s `Shape` interface. **Context:** The user wants to build a mesh from an image (e.g. a token or tile art) using `@tts/mesh`. vtracer only returns SVG, but the mesh package consumes `{ outline, holes }`. Tracing on the server keeps the SVG-to-shape parsing out of the browser and lets the BSON response carry a ready-to-extrude shape. `sharp` decodes the image to RGBA so alpha/luminance masks can be built before feeding vtracer's `convertPixels`. **Alternatives considered:** Returning only the raw SVG and parsing in the web app near `@tts/mesh`; tracing by color only (no alpha). Rejected — server-side parsing yields a shape the mesh package can consume directly, and alpha-based tracing (the default) is the common case for token/tile art. For shape inset/outset, `clipper-lib` (Angus Johnson's Clipper ported to JS) was chosen over the `polygon-offset` package because the latter crashes on degenerate cases (collapse, hole closure) via a bug in its pinned Martinez dependency. ## D14 — Extrusion exposes separated front/back/walls **Decision:** `extrudeShapeParts` returns `{ front, back, walls }` as separate geometries, and `tessellate.ts` exposes `frontFaces` / `backFaces` (with `capFaces` kept as a merged convenience). **Context:** Cards and tiles need distinct materials on the front and back faces, and the back must be flipped so it isn't mirrored when viewed from behind. Splitting the caps into front/back at the mesh level lets each viewer apply its own material without post-hoc geometry-group splitting. **Alternatives considered:** Returning a single merged caps geometry and splitting it in the viewer (the previous approach for cards). Rejected — required manual triangle-group bookkeeping in the component. ## D15 — Back faces are flipped on the material, not the geometry **Decision:** The back face is un-mirrored by flipping the texture on the material (`flipTexture.ts` negates `repeat.x` and shifts `offset.x`), rather than by transforming the geometry's UVs. **Context:** The back cap maps with the same planar UVs as the front, so without a flip it appears mirrored. Flipping on the material keeps the mesh geometry simple and shared, and works for both a full texture and a sprite cell. **Alternatives considered:** Flipping the UVs in `backFaces`. Rejected — would bake the flip into the shared mesh package, forcing it on every consumer rather than letting viewers opt in. ## D16 — Card sprite selection via `CardID` and the parent deck **Decision:** A card's face/back sprite is selected from the deck sheet by `CardID` (deck index in the hundreds place, 0-based card number in the last two digits). The sheet config (grid, face/back URLs, `UniqueBack`) is resolved from the containing deck's `CustomDeck[deckIndex]`, which is authoritative over the card's own `CustomDeck` (often keyed differently or absent). **Context:** Deck images are sheets divided into a `NumWidth` x `NumHeight` grid. The card's own `CustomDeck` field is unreliable — in the Wingspan dump a card with `CardID 1605` carries `CustomDeck: {14: ...}` even though its deck index is 16 — so the parent deck is the source of truth. **Alternatives considered:** Using the card's own `CustomDeck`. Rejected — produces the wrong sprite for cards whose own field is mis-keyed or missing. ## D17 — Tree selection by index path, not GUID **Decision:** The object tree selects nodes by their unique index path (e.g. `0-3-1`) rather than by `GUID`. **Context:** Cards in a deck share the deck's GUID — in the Wingspan dump, 292 of 606 objects carry a duplicate GUID. GUID-based selection highlighted every card with that GUID and rendered the first match, so the wrong sprite could show. **Alternatives considered:** Using `GUID` (the previous approach). Rejected — ambiguous for decked cards. ## D18 — Camera fit via drei `Bounds` inside Suspense **Decision:** The viewer camera is fitted to the object's bounds using drei's `Bounds` component, placed inside the scene's Suspense boundary so it mounts only after the (suspending) content has loaded. **Context:** Viewers load content asynchronously (textures suspend, tokens trace via the proxy, models stream in). `Bounds` fits on mount, so it must mount after the content is present. The token viewer was converted from `useEffect` + state to a suspending resource so it participates in the same boundary. **Alternatives considered:** A polling `CameraFit` that waited for non-empty bounds each frame; hand-rolled camera math. Rejected — Suspense already signals content readiness, so `Bounds` inside the boundary fits the loaded geometry directly.