diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa1905..8709dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). surface — not just `` / `` — and viewport hotkeys (`f`, `m`, `1`/`2`/`3`, Tab, Delete) stay quiet while an editor dialog is open. ([#34](https://github.com/StephenSHorton/wc3-forge/issues/34)) +- **Water missing on latest-Reforged maps.** The Terrain Cell inspector could + say "Has water: yes" while the viewport drew none. Two causes, both + triggered by maps saved in current Reforged World Editor: + - The water mesh used 16-bit indices and **bailed out entirely** on maps + with more than 65535 corners (anything around 256×256 and up). Water + now uses 32-bit indices, same as terrain and pathing. + - CASC lookups never tried the per-tileset `_tilesets/.w3mod:` + mounts that patch 2.0.3+ stores animated water (and other tileset art) + under, so frames 404'd and the leftover color-only plane was nearly + invisible. Lookups now follow HiveWE's tileset-mount order, and water + textures are requested as `.dds` (with `.blp` sibling fallback) like + the rest of the terrain. ([#35](https://github.com/StephenSHorton/wc3-forge/issues/35)) ## [1.0.6] - 2026-07-06 diff --git a/asset_handler.go b/asset_handler.go index afb3f6d..90efb69 100644 --- a/asset_handler.go +++ b/asset_handler.go @@ -30,9 +30,26 @@ type stockSource interface { ListByPrefix(prefix string) ([]string, error) SetReforged(b bool) Reforged() bool + SetTileset(ts byte) Close() error } +// applyMapTileset points the stock source at the loaded map's w3e tileset +// letter so CASC ReadFile tries `_tilesets/.w3mod:` mounts. Classic +// MPQ chains no-op this. Safe when no map is loaded (tileset 0). +func applyMapTileset(c stockSource) { + var ts byte + if t := forge.Current.Terrain(); t != nil { + ts = t.Tileset + } + c.SetTileset(ts) +} + +var ( + _ stockSource = (*casc.Storage)(nil) + _ stockSource = (*mpqchain.Chain)(nil) +) + // assetHandler serves /asset/ requests from Go-side asset sources. // mdx-m3-viewer's pathSolver returns "/asset/" strings; the viewer // then XHR-fetches via Wails' embedded HTTP server, which routes here. @@ -209,6 +226,7 @@ func (h *assetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // CASC fallback for stock WC3 assets — only after exhausting the map. if c, err := getCASC(); err == nil && c != nil { + applyMapTileset(c) for _, candidate := range candidates { data, ok, err := c.ReadFile(candidate) if err != nil { diff --git a/frontend/src/water.ts b/frontend/src/water.ts index ada7af2..38d4416 100644 --- a/frontend/src/water.ts +++ b/frontend/src/water.ts @@ -8,9 +8,10 @@ // blended by depth-above-ground) // // Texture animation: Water.slk's `texfile` column points at a base path; -// individual frames live at `00.blp` … `.blp` where -// NN ranges over `num_textures`. Each frame is a 128×128 BLP (or DDS in -// Reforged CASC; the asset-handler's BLP↔DDS fallback covers either). +// individual frames live at `00.dds` (Classic: `.blp` via the +// asset-handler sibling swap) where the index ranges over `num_textures`. +// Each frame is 128×128. CASC lookups also try the map's +// `_tilesets/.w3mod:` mount (Reforged 2.0.3+). // `texrate` is the playback rate in frames per second; current_frame = // floor(time_seconds * texrate) % numTextures. // @@ -188,10 +189,16 @@ export async function buildWater( } } - // uint16 indices cap at 65535 corners (~256×256 maps). Out of scope = no water. + // Shared-corner mesh: one vertex per grid corner. Maps at/over ~256 tiles + // have W*H > 65535, so we promote to uint32 indices the same way terrain + // and pathing do. The previous uint16-only path silently returned null + // here, which is why the Terrain Cell inspector could say "Has water: yes" + // on a latest-Reforged large map while the viewport drew none. const N = W * H - if (N > 65535) { - flog(`[water] vertex count ${N} exceeds 16-bit index limit; water disabled`) + const need32 = N > 65535 + const uintExt = need32 ? gl.getExtension('OES_element_index_uint') : null + if (need32 && !uintExt) { + flog(`[water] vertex count ${N} exceeds 16-bit index limit and OES_element_index_uint is missing; water disabled`) return null } @@ -209,7 +216,10 @@ export async function buildWater( } if (cellCount === 0) return null - const indices = new Uint16Array(cellCount * 6) + const indices: Uint16Array | Uint32Array = need32 + ? new Uint32Array(cellCount * 6) + : new Uint16Array(cellCount * 6) + const indexType = need32 ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT let idxPos = 0 for (let j = 0; j < H - 1; j++) { for (let i = 0; i < W - 1; i++) { @@ -243,10 +253,14 @@ export async function buildWater( // handler's BLP↔DDS extension fallback covers Reforged-only-DDS installs. const frameTextures: (WebGLTexture | null)[] = [] if (t.water.texture_file && t.water.num_textures > 0) { + // Reforged CASC ships these as DDS (no BLP). Request .dds the way + // terrain.ts does; the asset handler still sibling-swaps to .blp + // for Classic installs. Water.slk's texfile uses backslashes. + const stem = t.water.texture_file.replace(/\\/g, '/') const promises: Promise[] = [] for (let i = 0; i < t.water.num_textures; i++) { const num = i.toString().padStart(2, '0') - const path = `${t.water.texture_file}${num}.blp` + const path = `${stem}${num}.dds` promises.push( viewer.load(path, pathSolver).then((res: any) => { if (!res) return null @@ -302,7 +316,7 @@ export async function buildWater( gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) gl.disable(gl.CULL_FACE) - gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0) + gl.drawElements(gl.TRIANGLES, indices.length, indexType, 0) gl.depthMask(true) gl.disable(gl.BLEND) diff --git a/internal/casc/casc.go b/internal/casc/casc.go index 0dddc91..cb49901 100644 --- a/internal/casc/casc.go +++ b/internal/casc/casc.go @@ -123,6 +123,12 @@ type Storage struct { // to either the SD or HD asset depending on which mount we hit first. // Default (false) prefers SD assets. Use SetReforged to flip. reforged bool + // tileset is the current map's w3e tileset letter ('L', 'A', 'X', …). + // When non-zero, ReadFile tries the per-tileset CASC mounts + // (`_tilesets/.w3mod:`) that Reforged 2.0.3+ uses for terrain + // art — including animated water frames — before the generic mounts. + // Zero means "no map loaded"; prefixes match the legacy lists. + tileset byte } // SetReforged sets the HD/SD preference. Concurrent-safe. @@ -139,6 +145,22 @@ func (s *Storage) Reforged() bool { return s.reforged } +// SetTileset records the current map's tileset letter so ReadFile can try +// `_tilesets/.w3mod:` mounts. Pass 0 to clear (no map loaded). +// Concurrent-safe. +func (s *Storage) SetTileset(ts byte) { + s.mu.Lock() + s.tileset = ts + s.mu.Unlock() +} + +// Tileset returns the current map tileset letter, or 0 if none is set. +func (s *Storage) Tileset() byte { + s.mu.Lock() + defer s.mu.Unlock() + return s.tileset +} + // Open returns a Storage for the install at the given path. The path // should be the install ROOT (the directory containing .build.info), // e.g. "C:\\Program Files (x86)\\Warcraft III" or @@ -195,7 +217,8 @@ func (s *Storage) Close() error { // ReadFile fetches one file by its WC3-relative path (e.g. // "units/human/footman/footman.mdx" or its backslash equivalent). -// We try the standard set of CASC mount prefixes in order: `war3.w3mod:` +// We try CASC mount prefixes in order (see mountPrefixes): optional +// per-tileset `_tilesets/.w3mod:` mounts, then `war3.w3mod:` // (the main stock-asset mount), then localized + HD variants. Returns // (nil, false, nil) if the name isn't found in any mount. // @@ -209,10 +232,9 @@ func (s *Storage) Close() error { func (s *Storage) ReadFile(name string) ([]byte, bool, error) { // Normalize to backslash-style; CASC stores paths with `\`. bs := strings.ReplaceAll(name, "/", "\\") - prefixes := sdCascPrefixes - if s.Reforged() { - prefixes = hdCascPrefixes - } + s.mu.Lock() + prefixes := mountPrefixes(s.reforged, s.tileset) + s.mu.Unlock() for _, prefix := range prefixes { full := prefix + bs data, ok, err := s.openOne(full) @@ -226,6 +248,51 @@ func (s *Storage) ReadFile(name string) ([]byte, bool, error) { return nil, false, nil } +// tilesetMountLetter lowercases a w3e tileset byte to the letter CASC uses +// in `_tilesets/.w3mod:` (listfile entries are lowercase). Returns +// empty when ts is unset or not an ASCII letter. +func tilesetMountLetter(ts byte) string { + if ts >= 'A' && ts <= 'Z' { + ts += 'a' - 'A' + } + if ts < 'a' || ts > 'z' { + return "" + } + return string([]byte{ts}) +} + +// mountPrefixes is the ordered CASC TVFS prefix list for one ReadFile. +// tileset==0 reproduces sdCascPrefixes / hdCascPrefixes exactly so +// pre-map-load lookups and existing tests stay byte-identical. +// +// Reforged 2.0.3+ stores tileset-specific terrain art (ground textures, +// cliff textures, animated water frames) under +// `war3.w3mod:_hd.w3mod:_tilesets/.w3mod:` (HD) and +// `war3.w3mod:_tilesets/.w3mod:` (SD). HiveWE's hierarchy.ixx +// tries those mounts first; without them, water/terrain requests fall +// through to a generic (or missing) file and the viewport looks dry even +// though war3map.w3e still has the water flag set. +func mountPrefixes(reforged bool, tileset byte) []string { + base := sdCascPrefixes + if reforged { + base = hdCascPrefixes + } + letter := tilesetMountLetter(tileset) + if letter == "" { + return base + } + hdTs := "war3.w3mod:_hd.w3mod:_tilesets/" + letter + ".w3mod:" + sdTs := "war3.w3mod:_tilesets/" + letter + ".w3mod:" + head := []string{sdTs, hdTs} + if reforged { + head = []string{hdTs, sdTs} + } + out := make([]string, 0, 2+len(base)) + out = append(out, head...) + out = append(out, base...) + return out +} + // sdCascPrefixes are the CASC mount paths we try in SD (Classic) mode. // `war3.w3mod:` (the main shared SD-asset mount) is tried first; HD is // available as a fallback for assets that only exist HD-side (e.g. some diff --git a/internal/casc/casc_test.go b/internal/casc/casc_test.go index 8807bba..30989f5 100644 --- a/internal/casc/casc_test.go +++ b/internal/casc/casc_test.go @@ -93,6 +93,69 @@ func readCString(b []byte) string { return string(b) } +func TestMountPrefixes_NoTilesetMatchesLegacy(t *testing.T) { + sd := mountPrefixes(false, 0) + if len(sd) != len(sdCascPrefixes) { + t.Fatalf("SD no-tileset len=%d, want %d", len(sd), len(sdCascPrefixes)) + } + for i := range sd { + if sd[i] != sdCascPrefixes[i] { + t.Fatalf("SD no-tileset [%d]=%q, want %q", i, sd[i], sdCascPrefixes[i]) + } + } + hd := mountPrefixes(true, 0) + if len(hd) != len(hdCascPrefixes) { + t.Fatalf("HD no-tileset len=%d, want %d", len(hd), len(hdCascPrefixes)) + } + for i := range hd { + if hd[i] != hdCascPrefixes[i] { + t.Fatalf("HD no-tileset [%d]=%q, want %q", i, hd[i], hdCascPrefixes[i]) + } + } +} + +func TestMountPrefixes_TilesetLetter(t *testing.T) { + hd := mountPrefixes(true, 'A') + if hd[0] != "war3.w3mod:_hd.w3mod:_tilesets/a.w3mod:" { + t.Fatalf("HD tileset-A first prefix = %q", hd[0]) + } + if hd[1] != "war3.w3mod:_tilesets/a.w3mod:" { + t.Fatalf("HD tileset-A second prefix = %q", hd[1]) + } + // Legacy HD prefixes still follow the tileset mounts. + if hd[2] != hdCascPrefixes[0] { + t.Fatalf("HD tileset-A third prefix = %q, want legacy first %q", hd[2], hdCascPrefixes[0]) + } + + sd := mountPrefixes(false, 'X') + if sd[0] != "war3.w3mod:_tilesets/x.w3mod:" { + t.Fatalf("SD tileset-X first prefix = %q", sd[0]) + } + if sd[1] != "war3.w3mod:_hd.w3mod:_tilesets/x.w3mod:" { + t.Fatalf("SD tileset-X second prefix = %q", sd[1]) + } +} + +func TestTilesetMountLetter(t *testing.T) { + cases := []struct { + in byte + want string + }{ + {0, ""}, + {'A', "a"}, + {'a', "a"}, + {'X', "x"}, + {'L', "l"}, + {255, ""}, + {'1', ""}, + } + for _, tc := range cases { + if got := tilesetMountLetter(tc.in); got != tc.want { + t.Errorf("tilesetMountLetter(%q)=%q, want %q", tc.in, got, tc.want) + } + } +} + func TestReadCanonicalAssets(t *testing.T) { s, err := Open(wc3InstallPath(t)) if err != nil { @@ -110,6 +173,7 @@ func TestReadCanonicalAssets(t *testing.T) { // Caller-relative (no prefix) — ReadFile prepends mount prefixes. {"units/human/footman/footman.mdx", 1000}, {"units\\human\\footman\\footman.mdx", 1000}, + {"ReplaceableTextures/Water/Water00.dds", 1000}, // Note: not every well-known asset lives under war3.w3mod:; the // mount prefix list in casc.go will need to grow as misses surface. } @@ -129,3 +193,31 @@ func TestReadCanonicalAssets(t *testing.T) { }) } } + +func TestReadWaterTexture_WithTilesetMount(t *testing.T) { + s, err := Open(wc3InstallPath(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + + // Ashenvale ('A') and Reforged ('X') both have a tileset-specific water + // mount in current CASC; Lordaeron ('L') does not and must still fall + // through to the generic ReplaceableTextures/Water/Water00.dds. + for _, ts := range []byte{'A', 'X', 'L'} { + s.SetTileset(ts) + data, ok, err := s.ReadFile("ReplaceableTextures/Water/Water00.dds") + if err != nil { + t.Fatalf("tileset %c: ReadFile: %v", ts, err) + } + if !ok { + t.Fatalf("tileset %c: Water00.dds missing", ts) + } + if len(data) < 1000 { + t.Errorf("tileset %c: Water00.dds too small: %d bytes", ts, len(data)) + } + if string(data[:4]) != "DDS " { + t.Errorf("tileset %c: Water00.dds magic %q, want DDS ", ts, data[:4]) + } + } +} diff --git a/internal/mpqchain/chain.go b/internal/mpqchain/chain.go index 684dac3..2562f3d 100644 --- a/internal/mpqchain/chain.go +++ b/internal/mpqchain/chain.go @@ -244,6 +244,10 @@ func (c *Chain) Reforged() bool { return c.reforged } +// SetTileset is a no-op on Classic MPQ installs (no per-tileset mounts). +// It exists to satisfy the stock-source interface. +func (c *Chain) SetTileset(byte) {} + // Close releases every mounted archive. Idempotent; safe to call more than // once (it nils the archive slice, so a second call is a no-op). func (c *Chain) Close() error { diff --git a/typeindex.go b/typeindex.go index 7395de2..e3d4de9 100644 --- a/typeindex.go +++ b/typeindex.go @@ -141,6 +141,7 @@ func readBaseAsset(name string) ([]byte, bool, error) { if err != nil || c == nil { return nil, false, err } + applyMapTileset(c) return c.ReadFile(clean) }