Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
surface — not just `<input>` / `<textarea>` — 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/<letter>.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

Expand Down
18 changes: 18 additions & 0 deletions asset_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<letter>.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/<path> requests from Go-side asset sources.
// mdx-m3-viewer's pathSolver returns "/asset/<src>" strings; the viewer
// then XHR-fetches via Wails' embedded HTTP server, which routes here.
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 23 additions & 9 deletions frontend/src/water.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<texfile>00.blp` … `<texfile><NN>.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 `<texfile>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/<letter>.w3mod:` mount (Reforged 2.0.3+).
// `texrate` is the playback rate in frames per second; current_frame =
// floor(time_seconds * texrate) % numTextures.
//
Expand Down Expand Up @@ -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
}

Expand All @@ -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++) {
Expand Down Expand Up @@ -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<WebGLTexture | null>[] = []
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
Expand Down Expand Up @@ -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)
Expand Down
77 changes: 72 additions & 5 deletions internal/casc/casc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<letter>.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.
Expand All @@ -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/<letter>.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
Expand Down Expand Up @@ -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/<letter>.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.
//
Expand All @@ -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)
Expand All @@ -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/<letter>.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/<letter>.w3mod:` (HD) and
// `war3.w3mod:_tilesets/<letter>.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
Expand Down
92 changes: 92 additions & 0 deletions internal/casc/casc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
}
Expand All @@ -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])
}
}
}
4 changes: 4 additions & 0 deletions internal/mpqchain/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions typeindex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading