Files
tts-workshop/docs/decisions.md
T

7.8 KiB

Decisions

Scope: The rationale behind key design decisions. For the system's architecture, see architecture.md. For the concrete build plan, see 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 <img> / <object> 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, and format (shape default, svg) selects the response. 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.