LosaDev Tools: Lost Saga modding tools in the browser
This commit is contained in:
commit
c5e04472fb
105 files changed
+38541
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
reference/
|
||||
*.log
|
||||
|
||||
HANDOFF.md
|
||||
PLAN.md
|
||||
@@ -0,0 +1,39 @@
|
||||
# LosaDev Tools
|
||||
|
||||
Lost Saga modding tools that run in the browser: **https://tools.losadev.wiki**
|
||||
|
||||
Open a game folder, browse and edit `.iop` packs, preview models, animations, effects, maps, UI windows and textures, look up items, heroes, costumes and quests, and encode/decode server values. Game files never leave your PC: everything runs in your browser tab, and changed packs come back as downloads.
|
||||
|
||||
Guides for every tool: https://losadev.wiki/tools/overview/ · tutorials: https://losadev.wiki/tools/tutorials/
|
||||
|
||||
## Run it locally
|
||||
|
||||
Requirements: Node.js 20 or newer.
|
||||
|
||||
```bash
|
||||
cd app
|
||||
npm install
|
||||
npm run dev # http://localhost:4700
|
||||
```
|
||||
|
||||
`npm run build` writes the static site to `app/dist/`; `npm start` serves it with the small server in `app/server.mjs` (port and address in `app/.env`, see `app/.env.example` and `app/DEPLOY.md`).
|
||||
|
||||
The folder picker needs a Chromium-based browser (Chrome, Edge) on HTTPS or `localhost`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `app/src/` | The app: game folder, worker with the pack reader (`src/worker/`), IOP Manager (`src/iop/`), Encode / Decode (`src/cipher/`), workbench layout (`src/legacy/workbench.*`) |
|
||||
| `app/public/legacy/` | Tool views shared with the desktop LS Tools. **Generated**: change them through `app/tools/legacy-patches.mjs`, then `node tools/extract-legacy.mjs` |
|
||||
| `app/tools/` | Build helpers (legacy extraction, handler generation) |
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome and count toward your LosaDev tier: fixes, new file formats, better previews, UI work, tutorials. Read https://losadev.wiki/contribute/how-to/ first.
|
||||
|
||||
- One change per pull request, with a short description and how you tested it (which client year, which pack).
|
||||
- Never commit game files, packs, or passwords of your own server. The built-in pack passwords are the defaults from the shared sources.
|
||||
- Pull requests are reviewed and merged by the **Core** team.
|
||||
|
||||
Lost Saga is the property of its publishers. This project is fan-made, for development and preservation, and not affiliated with them.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copy to .env and change as needed. Read by server.mjs (npm start / npm run rebuild).
|
||||
# Port the tools server listens on.
|
||||
PORT=4700
|
||||
# Address to bind. 127.0.0.1 = only the local reverse proxy can reach it (recommended);
|
||||
# 0.0.0.0 = open on every network interface.
|
||||
HOST=127.0.0.1
|
||||
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# server settings (copy .env.example)
|
||||
.env
|
||||
@@ -0,0 +1,44 @@
|
||||
# Deploying LosaDev Tools
|
||||
|
||||
The tools are a static site (Vite build in `dist/`) served by `server.mjs`, a small Node server with no extra dependencies.
|
||||
|
||||
## Needs
|
||||
|
||||
- Node.js 20.19+ or 22.12+ (same as Vite).
|
||||
- HTTPS on the public domain (for example `tools.losadev.wiki` behind the panel's reverse proxy). Browsers only allow opening a game folder (File System Access API) on HTTPS or `localhost`.
|
||||
|
||||
## First time
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # then edit PORT / HOST
|
||||
npm run rebuild # = npm run build && npm run start
|
||||
```
|
||||
|
||||
`npm install` must include the dev dependencies (Vite builds the site). Do not set `NODE_ENV=production` before installing.
|
||||
|
||||
## Settings (`.env`)
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `PORT` | `4700` | Port the server listens on |
|
||||
| `HOST` | `0.0.0.0` | Address to bind. Use `127.0.0.1` when a reverse proxy on the same machine forwards to it |
|
||||
|
||||
Values already set in the real environment win over `.env`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Does |
|
||||
|---|---|
|
||||
| `npm run build` | Builds the site into `dist/` |
|
||||
| `npm run start` | Serves `dist/` (stops with a message if there is no build yet) |
|
||||
| `npm run rebuild` | Build, then start. Use it after every update |
|
||||
| `npm run dev` | Development server (port 4700, not for the public site) |
|
||||
|
||||
## Updating
|
||||
|
||||
Replace the files (keep your `.env`), run `npm install` if `package.json` changed, then `npm run rebuild` (or restart the process manager after `npm run build`).
|
||||
|
||||
## Caching
|
||||
|
||||
Files in `/assets/` carry a content hash and are cached for a year. `index.html` and the `legacy/` files are revalidated on every visit, so a new build shows up right away.
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0e1324" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>LosaDev Tools</title>
|
||||
<meta name="description" content="Lost Saga developer tools in the browser: UI image sets, windows, models, maps, effects, items, quests and tables. Your files never leave your PC." />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<a class="brand" href="#/">
|
||||
<img src="/logo.png" alt="" width="34" height="34" />
|
||||
<span>LosaDev <em>Tools</em></span>
|
||||
</a>
|
||||
<div class="top-status" id="game-status"></div>
|
||||
<nav class="top-links">
|
||||
<a href="https://losadev.wiki" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3Z" /></svg>
|
||||
<span>Wiki</span>
|
||||
</a>
|
||||
<a href="https://git.losadev.wiki" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9" /></svg>
|
||||
<span>Git</span>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="side" id="side"></aside>
|
||||
<main class="main" id="main"></main>
|
||||
</div>
|
||||
<div class="toasts" id="toasts"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+943
@@ -0,0 +1,943 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/cinzel": "^5.3.0",
|
||||
"buffer": "^6.0.3",
|
||||
"fflate": "^0.8.3",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"acorn": "^8.19.0",
|
||||
"vite": "^8.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/cinzel": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz",
|
||||
"integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.156.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||
"integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
|
||||
"integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
|
||||
"integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
|
||||
"integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
|
||||
"integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
|
||||
"integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
|
||||
"integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
|
||||
"integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
|
||||
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.156.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.16",
|
||||
"@rolldown/binding-android-arm64": "1.2.16",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.16",
|
||||
"@rolldown/binding-darwin-x64": "1.2.16",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.16",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.16",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.16",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.16",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.16",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.16"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz",
|
||||
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.32",
|
||||
"rolldown": "~1.2.16",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"start": "node server.mjs",
|
||||
"rebuild": "npm run build && npm run start",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"acorn": "^8.19.0",
|
||||
"vite": "^8.3.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/cinzel": "^5.3.0",
|
||||
"buffer": "^6.0.3",
|
||||
"fflate": "^0.8.3",
|
||||
"path-browserify": "^1.0.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,8 @@
|
||||
<div id="menu" class="menu" hidden></div>
|
||||
<div id="modal" hidden><div class="dialog"><h3 id="modal-title"></h3><div id="modal-body"></div><div class="actions" id="modal-actions"></div></div></div>
|
||||
<div id="toast"></div>
|
||||
<div id="task" hidden>
|
||||
<span id="task-text"></span>
|
||||
<div class="bar-track"><i id="task-bar"></i></div>
|
||||
<button id="task-cancel" class="iconbtn">Cancel</button>
|
||||
</div>
|
||||
@@ -0,0 +1,260 @@
|
||||
"use strict";
|
||||
// Generated by tools/extract-legacy.mjs: helpers copied from the desktop app.js + web additions.
|
||||
|
||||
// ---- web additions (src/legacy/host-web.js) ----
|
||||
// The desktop app.js kept these as its own state; in the web version they come from the site.
|
||||
|
||||
// The active "profile": the game folder opened on the Game Folder page (mounted at /game in the
|
||||
// worker). profileSource() (copied below) turns it into the tools' default asset source.
|
||||
var WS = null;
|
||||
/** Called by the site when the game folder changes. kind: "packs" (has .iop) or "dir" (unpacked). */
|
||||
function setWebGameFolder(name, kind) {
|
||||
WS = name ? { id: "web", name, gameDir: "/game", sourceKind: kind || "packs" } : null;
|
||||
}
|
||||
|
||||
// IOP Manager is not part of the web tools yet: no open sessions.
|
||||
var IOP = { files: [], active: null, rows: [], cwd: "", profiles: [], index: null, expanded: new Set(), mode: "folders" };
|
||||
|
||||
/** Turn <i data-icon="x"> placeholders into SVG icons and wire data-cmd buttons (app.js did this once at load). */
|
||||
function hydrateLegacy(root) {
|
||||
for (const n of root.querySelectorAll("[data-icon]")) n.replaceWith(Object.assign(icon(n.dataset.icon), { className: `ic ${n.className}`.trim() }));
|
||||
for (const b of root.querySelectorAll("[data-cmd]")) b.addEventListener("click", () => $(b.dataset.cmd).onclick?.());
|
||||
}
|
||||
|
||||
|
||||
// ---- app.js 6-103 ----
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const bridge = window.api; // (window.api itself is a read-only global from the preload)
|
||||
const call = bridge.call;
|
||||
|
||||
const el = (tag, props = {}, children = []) => {
|
||||
const n = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (k === "class") n.className = v;
|
||||
else if (k === "text") n.textContent = v;
|
||||
else if (k === "style") n.style.cssText = v || ""; // CSSOM: allowed by the page's CSP, style="" isn't
|
||||
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
|
||||
else if (v !== undefined && v !== null && v !== false) n.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const c of [].concat(children)) if (c != null) n.append(c);
|
||||
return n;
|
||||
};
|
||||
const size = (n) => (n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`);
|
||||
const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
|
||||
const fmtDos = (date, time) => {
|
||||
if (!date) return "";
|
||||
const y = 1980 + (date >> 9), m = (date >> 5) & 15, d = date & 31;
|
||||
const hh = time >> 11, mm = (time >> 5) & 63;
|
||||
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
|
||||
};
|
||||
const MAX_ENTRIES = 65535; // client limit: ZipArchive without ZIP64, 16-bit entry index
|
||||
|
||||
// ---------- toast / modal / task ----------
|
||||
function toast(msg, bad = false) {
|
||||
const t = el("div", { class: `toast${bad ? " bad" : ""}`, text: msg, onclick: () => hide() });
|
||||
const hide = () => {
|
||||
t.classList.add("out");
|
||||
setTimeout(() => t.remove(), 220);
|
||||
};
|
||||
$("toast").append(t);
|
||||
setTimeout(hide, bad ? 8000 : 4500);
|
||||
}
|
||||
|
||||
let modalSeq = 0;
|
||||
function modal(title, body, buttons) {
|
||||
return new Promise((resolve) => {
|
||||
// a modal opened right after another closes: the old one's close timer must not hide it
|
||||
const seq = ++modalSeq;
|
||||
$("modal").classList.remove("closing");
|
||||
$("modal-title").textContent = title;
|
||||
$("modal-body").replaceChildren(...[].concat(body).map((b) => (typeof b === "string" ? el("p", { text: b }) : b)));
|
||||
$("modal-actions").replaceChildren(
|
||||
...buttons.map((b) =>
|
||||
el("button", {
|
||||
class: `btn ${b.kind || ""}`,
|
||||
text: b.label,
|
||||
onclick: () => {
|
||||
$("modal").classList.add("closing");
|
||||
setTimeout(() => {
|
||||
if (seq !== modalSeq) return;
|
||||
$("modal").hidden = true;
|
||||
$("modal").classList.remove("closing");
|
||||
}, 160);
|
||||
resolve(b.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
$("modal").hidden = false;
|
||||
$("modal-body").querySelector("input,select")?.focus();
|
||||
});
|
||||
}
|
||||
const confirmBox = (title, text, ok = "OK", kind = "primary") => modal(title, text, [{ label: "Cancel", value: false }, { label: ok, kind, value: true }]);
|
||||
async function prompt(title, label, value = "") {
|
||||
const input = el("input", { type: "text", value, spellcheck: "false" });
|
||||
input.addEventListener("keydown", (e) => e.key === "Enter" && $("modal-actions").lastChild.click());
|
||||
const ok = await modal(title, [el("label", { text: label }), input], [{ label: "Cancel", value: false }, { label: "OK", kind: "primary", value: true }]);
|
||||
return ok ? input.value.trim() : null;
|
||||
}
|
||||
|
||||
let taskSeq = 0;
|
||||
const taskHandlers = new Map();
|
||||
bridge.onTask((p) => taskHandlers.get(p.taskId)?.(p));
|
||||
|
||||
/** Runs a main-process task with the progress bar; `label(p)` turns progress into text. */
|
||||
async function runTask(channel, args, { label = () => "Working…", onEvent } = {}) {
|
||||
const id = `t${++taskSeq}`;
|
||||
$("task").hidden = false;
|
||||
$("task-text").textContent = "Starting…";
|
||||
$("task-bar").style.width = "0%";
|
||||
$("task-cancel").onclick = () => call("task:cancel", id);
|
||||
taskHandlers.set(id, (p) => {
|
||||
onEvent?.(p);
|
||||
if (p.type === "log") return;
|
||||
$("task-text").textContent = label(p);
|
||||
if (p.total) $("task-bar").style.width = `${Math.min(100, (100 * (p.bytesTotal ? p.bytes / p.bytesTotal : p.done / p.total)).toFixed(1))}%`;
|
||||
});
|
||||
try {
|
||||
return await call(channel, id, ...args);
|
||||
} finally {
|
||||
taskHandlers.delete(id);
|
||||
$("task").hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- app.js 162-162 ----
|
||||
const baseName = (p) => p.replace(/\/$/, "").split("/").pop();
|
||||
|
||||
// ---- app.js 494-558 ----
|
||||
const ICONS = {
|
||||
box: '<path d="M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5z"/><path d="M3.5 7.5 12 12l8.5-4.5M12 12v9"/>',
|
||||
pulse: '<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>',
|
||||
power: '<path d="M12 3v8"/><path d="M6.4 7.2a7.5 7.5 0 1 0 11.2 0"/>',
|
||||
upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/>',
|
||||
gem: '<path d="M6 4h12l3 5-9 11L3 9z"/><path d="M3 9h18M9 4l3 16M15 4l-3 16"/>',
|
||||
rocket: '<path d="M12 3c3 2 5 5.5 5 9.5L15 16H9l-2-3.5C7 8.5 9 5 12 3z"/><circle cx="12" cy="10" r="1.6"/><path d="M9 16l-2.5 3M15 16l2.5 3M12 17v4"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l3 3M14 9l2 2"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
more: '<circle cx="5" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="19" cy="12" r="1.4"/>',
|
||||
grid: '<rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="4" y="13" width="7" height="7" rx="1"/><rect x="13" y="13" width="7" height="7" rx="1"/>',
|
||||
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9"/><circle cx="4.5" cy="12" r=".9"/><circle cx="4.5" cy="18" r=".9"/>',
|
||||
up: '<path d="M12 19V5M6 11l6-6 6 6"/>',
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>',
|
||||
download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M4 18v1a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-1"/>',
|
||||
swap: '<path d="M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7"/>',
|
||||
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
|
||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||
fileplus: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M12 11v6M9 14h6"/>',
|
||||
folderplus: '<path d="M3 6h6l2 2h10v11H3z"/><path d="M12 11v5M9.5 13.5h5"/>',
|
||||
folder: '<path d="M3 6h6l2 2h10v11H3z"/>',
|
||||
external: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
|
||||
note: '<path d="M6 3h9l3 3v15H6z"/><path d="M9 9h6M9 13h6M9 17h4"/>',
|
||||
apps: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M17 14v6M14 17h6"/>',
|
||||
shield: '<path d="M12 3 5 6v6c0 4 3 7.5 7 9 4-1.5 7-5 7-9V6z"/><path d="m9 12 2 2 4-4"/>',
|
||||
zap: '<path d="M13 3 5 13h6l-1 8 8-10h-6z"/>',
|
||||
lock: '<rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
|
||||
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>',
|
||||
newfile: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',
|
||||
import: '<path d="M12 4v11M7 10l5 5 5-5"/><path d="M5 20h14"/>',
|
||||
server: '<rect x="4" y="4" width="16" height="7" rx="1.5"/><rect x="4" y="13" width="16" height="7" rx="1.5"/><path d="M8 7.5h.01M8 16.5h.01"/>',
|
||||
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
||||
image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4 18 5-5 3 3 3-3 5 5"/>',
|
||||
undo: '<path d="M9 7 4 12l5 5"/><path d="M4 12h10a6 6 0 0 1 0 12"/>',
|
||||
redo: '<path d="m15 7 5 5-5 5"/><path d="M20 12H10a6 6 0 0 0 0 12"/>',
|
||||
save: '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
||||
draw: '<rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 2.5"/><path d="M12 8v8M8 12h8"/>',
|
||||
copy: '<rect x="8" y="8" width="12" height="12" rx="1.5"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>',
|
||||
cube: '<path d="M12 3 4 7.5v9L12 21l8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/><path d="m8 5.3 8 4.5" opacity=".45"/>',
|
||||
play: '<path d="M8 5v14l11-7z"/>',
|
||||
pause: '<path d="M8 5v14M16 5v14"/>',
|
||||
brush: '<path d="M14.5 4.5 19.5 9.5 11 18l-5-5z"/><path d="M6 13c-2 0-3 1.5-3 3.5S2 20 2 20s4 .5 5.5-1 1.5-3 1.5-3"/><path d="m16.5 2.5 5 5"/>',
|
||||
shirt: '<path d="M8.5 3.5 4 6l-1.5 4.5 3 1.2V20.5h13V11.7l3-1.2L20 6l-4.5-2.5c-.6 1.6-2 2.5-3.5 2.5S9.1 5.1 8.5 3.5z"/>',
|
||||
bone: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="m7.6 7.6 8.8 8.8"/>',
|
||||
target: '<circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>',
|
||||
floor: '<path d="M3 16 9 8h6l6 8z"/><path d="M12 8v8M6 12h12"/>',
|
||||
focus: '<path d="M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4"/><circle cx="12" cy="12" r="2.5"/>',
|
||||
camera: '<path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/>',
|
||||
layout: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9h17M9 9v10.5"/>',
|
||||
map: '<path d="M3 6.5 9 4l6 2.5L21 4v13.5L15 20l-6-2.5L3 20z"/><path d="M9 4v13.5M15 6.5V20"/>',
|
||||
scroll: '<path d="M7 4h11a2 2 0 0 1 2 2v1h-4"/><path d="M16 7v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1h10"/><path d="M7 4a2 2 0 0 0-2 2v11"/><path d="M8.5 9h4.5M8.5 12.5h4.5"/>',
|
||||
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
||||
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
|
||||
grid: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9.5h17M3.5 14.5h17M9 4.5v15M15 4.5v15"/>',
|
||||
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/>',
|
||||
redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/>',
|
||||
};
|
||||
function icon(name) {
|
||||
const i = document.createElement("i");
|
||||
i.className = "ic";
|
||||
i.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name] || ""}</svg>`;
|
||||
return i;
|
||||
}
|
||||
|
||||
// ---- app.js 564-593 ----
|
||||
// ---------- menus (dropdowns + right-click) ----------
|
||||
function showMenu(items, { x, y, anchor } = {}) {
|
||||
const m = $("menu");
|
||||
m.replaceChildren(
|
||||
...items.map((it) =>
|
||||
it === "-"
|
||||
? el("div", { class: "sep" })
|
||||
: el("button", { class: `item${it.danger ? " danger" : ""}`, disabled: !!it.disabled, onclick: () => (hideMenu(), it.run()) }, [icon(it.icon || "none"), el("span", { text: it.label }), it.hint ? el("kbd", { text: it.hint }) : null]),
|
||||
),
|
||||
);
|
||||
m.hidden = false;
|
||||
const r = anchor?.getBoundingClientRect();
|
||||
const w = m.offsetWidth;
|
||||
const h = m.offsetHeight;
|
||||
let left = anchor ? r.right - w : x;
|
||||
let top = anchor ? r.bottom + 6 : y;
|
||||
left = Math.max(8, Math.min(left, innerWidth - w - 8));
|
||||
if (top + h > innerHeight - 8) top = anchor ? r.top - h - 6 : innerHeight - h - 8;
|
||||
m.style.left = `${left}px`;
|
||||
m.style.top = `${top}px`;
|
||||
replay(m, "open");
|
||||
}
|
||||
function hideMenu() {
|
||||
$("menu").hidden = true;
|
||||
}
|
||||
document.addEventListener("mousedown", (e) => !e.target.closest("#menu") && hideMenu());
|
||||
document.addEventListener("keydown", (e) => e.key === "Escape" && hideMenu());
|
||||
window.addEventListener("blur", hideMenu);
|
||||
|
||||
const cmd = (id) => ({ disabled: $(id).disabled, run: () => $(id).onclick() });
|
||||
|
||||
// ---- app.js 818-823 ----
|
||||
/** Restarts a CSS animation class. */
|
||||
function replay(node, cls) {
|
||||
node.classList.remove(cls);
|
||||
void node.offsetWidth;
|
||||
node.classList.add(cls);
|
||||
}
|
||||
|
||||
// ---- app.js 1051-1063 ----
|
||||
/** Editors with unsaved changes (their state lives in the window, a reload would drop it). */
|
||||
function unsavedWork() {
|
||||
const out = [];
|
||||
if (window.FXE && window.FXE.text !== window.FXE.saved) out.push("FX");
|
||||
if (window.MAP?.dirty) out.push("Map");
|
||||
if (window.WND?.dirty) out.push("Wnd");
|
||||
if (typeof IMS !== "undefined" && IMS.dirty) out.push("UI image sets"); // (imageset.js: a script-level const)
|
||||
if (window.MDL?.dirtyItems?.().length) out.push("3D");
|
||||
if (window.ANS?.clip?.dirty) out.push("Anim");
|
||||
if (window.COS?.dirty) out.push("Costume");
|
||||
if (window.TEX?.dirty) out.push("Texture");
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- web overrides (src/legacy/host-web-after.js), loaded after the app.js copies ----
|
||||
|
||||
// Same as the desktop, but an unpacked game folder (no .iop) is a "dir" source, not "packs".
|
||||
function profileSource() {
|
||||
return WS?.gameDir ? { kind: WS.sourceKind === "dir" ? "dir" : "packs", folder: WS.gameDir, name: WS.name } : null;
|
||||
}
|
||||
function profileSourceOption() {
|
||||
const s = profileSource();
|
||||
return s ? [{ value: s, title: `${WS.name} — game folder`, sub: s.kind === "dir" ? "unpacked files" : "its packs" }] : [];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,754 @@
|
||||
"use strict";
|
||||
|
||||
// Anim Studio maths, no UI (also runs under node for tests):
|
||||
// Q quaternions the io3DEngine way (x, y, z, w; world = qmul(parentWorld, local), qrot rotates)
|
||||
// clips an editable .ani: tracks of keys { t (ms), p, q, raw, rv } — raw / rv are the stored rotation
|
||||
// words and the .ani version they belong to, written back untouched while the key isn't edited
|
||||
// retarget any rig → a Lost Saga skeleton: world rotations moved over with the rest poses aligned,
|
||||
// the root scaled by leg length, the feet put where the source's feet are (two-bone IK) so
|
||||
// they stay on the floor, then baked to keys and thinned
|
||||
// ops trim / cut / speed / reverse / insert / loop / mirror on clips
|
||||
|
||||
const AnimKit = (() => {
|
||||
// =====================================================================
|
||||
// quaternions + vectors
|
||||
// =====================================================================
|
||||
const Q = {
|
||||
id: () => [0, 0, 0, 1],
|
||||
mul: (a, b) => [
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
],
|
||||
conj: (q) => [-q[0], -q[1], -q[2], q[3]],
|
||||
norm(q) {
|
||||
const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
|
||||
return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
|
||||
},
|
||||
rot(q, v) {
|
||||
const [x, y, z, w] = q;
|
||||
const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0];
|
||||
const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux;
|
||||
return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)];
|
||||
},
|
||||
axis(a, ang) {
|
||||
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
||||
const s = Math.sin(ang / 2) / l;
|
||||
return [a[0] * s, a[1] * s, a[2] * s, Math.cos(ang / 2)];
|
||||
},
|
||||
/** Shortest rotation taking direction u to direction v. */
|
||||
fromTo(u, v) {
|
||||
const a = V.norm(u), b = V.norm(v);
|
||||
const d = V.dot(a, b);
|
||||
if (d > 1 - 1e-9) return [0, 0, 0, 1];
|
||||
if (d < -1 + 1e-9) {
|
||||
const ax = Math.abs(a[0]) < 0.9 ? V.cross(a, [1, 0, 0]) : V.cross(a, [0, 1, 0]);
|
||||
return Q.axis(ax, Math.PI);
|
||||
}
|
||||
const c = V.cross(a, b);
|
||||
return Q.norm([c[0], c[1], c[2], 1 + d]);
|
||||
},
|
||||
slerp(a, b, f) {
|
||||
let [bx, by, bz, bw] = b;
|
||||
let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw;
|
||||
if (cos < 0) {
|
||||
cos = -cos;
|
||||
bx = -bx; by = -by; bz = -bz; bw = -bw;
|
||||
}
|
||||
let s0, s1;
|
||||
if (cos > 0.9995) {
|
||||
s0 = 1 - f;
|
||||
s1 = f;
|
||||
} else {
|
||||
const th = Math.acos(cos);
|
||||
const sin = Math.sin(th);
|
||||
s0 = Math.sin((1 - f) * th) / sin;
|
||||
s1 = Math.sin(f * th) / sin;
|
||||
}
|
||||
return Q.norm([s0 * a[0] + s1 * bx, s0 * a[1] + s1 * by, s0 * a[2] + s1 * bz, s0 * a[3] + s1 * bw]);
|
||||
},
|
||||
/** Angle between two rotations (radians). */
|
||||
angle(a, b) {
|
||||
const d = Math.abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]);
|
||||
return 2 * Math.acos(Math.min(1, d));
|
||||
},
|
||||
/** Rotation whose basis maps the rest frame (x, y, z) onto the current one (orthonormal). */
|
||||
fromBases(rest, cur) {
|
||||
const m = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // row-major R, R·r_i = c_i
|
||||
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) m[j * 3 + k] += cur[i][j] * rest[i][k];
|
||||
return Q.fromMat(m);
|
||||
},
|
||||
fromMat(m) {
|
||||
const [m00, m01, m02, m10, m11, m12, m20, m21, m22] = m;
|
||||
const tr = m00 + m11 + m22;
|
||||
let q;
|
||||
if (tr > 0) {
|
||||
const s = Math.sqrt(tr + 1) * 2;
|
||||
q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||
q = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
|
||||
} else if (m11 > m22) {
|
||||
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||
q = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
|
||||
} else {
|
||||
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||
q = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
|
||||
}
|
||||
return Q.norm(q);
|
||||
},
|
||||
/** XYZ Euler angles in degrees (q = Rz·Ry·Rx applied to column vectors) and back. */
|
||||
toEuler(q) {
|
||||
const [x, y, z, w] = q;
|
||||
const sx = 2 * (w * x + y * z), cx = 1 - 2 * (x * x + y * y);
|
||||
const sy = Math.max(-1, Math.min(1, 2 * (w * y - z * x)));
|
||||
const sz = 2 * (w * z + x * y), cz = 1 - 2 * (y * y + z * z);
|
||||
const d = 180 / Math.PI;
|
||||
return [Math.atan2(sx, cx) * d, Math.asin(sy) * d, Math.atan2(sz, cz) * d];
|
||||
},
|
||||
fromEuler(e) {
|
||||
const r = Math.PI / 180;
|
||||
return Q.mul(Q.axis([0, 0, 1], e[2] * r), Q.mul(Q.axis([0, 1, 0], e[1] * r), Q.axis([1, 0, 0], e[0] * r)));
|
||||
},
|
||||
};
|
||||
const V = {
|
||||
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
|
||||
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
|
||||
scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
|
||||
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
|
||||
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
|
||||
len: (a) => Math.hypot(a[0], a[1], a[2]),
|
||||
norm(a) {
|
||||
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
||||
return [a[0] / l, a[1] / l, a[2] / l];
|
||||
},
|
||||
lerp: (a, b, f) => [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f],
|
||||
};
|
||||
/** Orthonormal frame from a direction and a reference vector (the bend plane). */
|
||||
function frame(dir, ref) {
|
||||
const x = V.norm(dir);
|
||||
let z = V.cross(x, ref);
|
||||
if (V.len(z) < 1e-6) z = V.cross(x, Math.abs(x[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]);
|
||||
z = V.norm(z);
|
||||
return [x, V.cross(z, x), z];
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// clips
|
||||
// =====================================================================
|
||||
const S = (text) => ({ raw: null, text });
|
||||
const nameOf = (s) => (typeof s === "string" ? s : s.text);
|
||||
|
||||
/** LSF.parseAni result → editable clip. */
|
||||
function fromLsf(a, name = "") {
|
||||
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4;
|
||||
return {
|
||||
name,
|
||||
version: a.version,
|
||||
total: a.total,
|
||||
events: a.events.map((e) => ({ type: e.type, sub: e.sub, time: e.time })),
|
||||
tracks: a.tracks.map((t) => {
|
||||
const keys = [];
|
||||
for (let k = 0; k < t.times.length; k++)
|
||||
keys.push({
|
||||
t: t.times[k],
|
||||
p: [t.trans[k * 3], t.trans[k * 3 + 1], t.trans[k * 3 + 2]],
|
||||
q: [t.rot[k * 4], t.rot[k * 4 + 1], t.rot[k * 4 + 2], t.rot[k * 4 + 3]],
|
||||
raw: Array.from(t.rawRot.subarray(k * words, k * words + words)),
|
||||
rv: a.version,
|
||||
});
|
||||
return { name: t.name, weight: t.weight, keys };
|
||||
}),
|
||||
trailing: a.trailing,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clip → the object LSF.writeAni writes (untouched keys keep their stored words). */
|
||||
function toLsf(c) {
|
||||
const words = c.version === 4001 ? 1 : c.version === 4002 ? 2 : 4;
|
||||
return {
|
||||
version: c.version,
|
||||
events: c.events.map((e) => ({ type: typeof e.type === "string" ? S(e.type) : e.type, sub: typeof e.sub === "string" ? S(e.sub) : e.sub, time: e.time })),
|
||||
total: Math.max(0, Math.round(c.total)),
|
||||
tracks: c.tracks.map((t) => {
|
||||
const keys = t.keys.slice().sort((a, b) => a.t - b.t);
|
||||
const n = keys.length;
|
||||
const out = { name: typeof t.name === "string" ? S(t.name) : t.name, weight: t.weight, times: new Int32Array(n), trans: new Float32Array(n * 3), rot: new Float32Array(n * 4), rawRot: new Uint32Array(n * words) };
|
||||
keys.forEach((k, i) => {
|
||||
out.times[i] = Math.round(k.t);
|
||||
out.trans.set(k.p, i * 3);
|
||||
out.rot.set(k.q, i * 4);
|
||||
const raw = k.raw && k.rv === c.version && k.raw.length === words ? k.raw : LSF.encodeRot(c.version, k.q);
|
||||
out.rawRot.set(raw, i * words);
|
||||
});
|
||||
return out;
|
||||
}),
|
||||
trailing: c.trailing,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyClip(name, version = 4002, total = 1000) {
|
||||
return { name, version, total, events: [], tracks: [], trailing: undefined };
|
||||
}
|
||||
const key = (t, p, q) => ({ t, p: p.slice(), q: Q.norm(q), raw: null, rv: 0 });
|
||||
const copyKey = (k, t = k.t) => ({ t, p: k.p.slice(), q: k.q.slice(), raw: k.raw ? k.raw.slice() : null, rv: k.rv });
|
||||
|
||||
/** Interpolated key value at t (ms): lerp position, slerp rotation, held at the ends. */
|
||||
function sample(keys, t) {
|
||||
const n = keys.length;
|
||||
if (!n) return null;
|
||||
if (t <= keys[0].t) return { p: keys[0].p.slice(), q: keys[0].q.slice() };
|
||||
if (t >= keys[n - 1].t) return { p: keys[n - 1].p.slice(), q: keys[n - 1].q.slice() };
|
||||
let lo = 0, hi = n - 1;
|
||||
while (hi - lo > 1) {
|
||||
const m = (lo + hi) >> 1;
|
||||
if (keys[m].t <= t) lo = m;
|
||||
else hi = m;
|
||||
}
|
||||
const a = keys[lo], b = keys[hi];
|
||||
const f = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1;
|
||||
return { p: V.lerp(a.p, b.p, f), q: Q.slerp(a.q, b.q, f) };
|
||||
}
|
||||
|
||||
/** Puts a key at t (replacing one within 0.5 ms). → the key */
|
||||
function setKey(track, t, p, q) {
|
||||
t = Math.round(t);
|
||||
let i = track.keys.findIndex((k) => Math.abs(k.t - t) < 0.5);
|
||||
const k = key(t, p, q);
|
||||
if (i >= 0) track.keys[i] = k;
|
||||
else {
|
||||
i = track.keys.findIndex((x) => x.t > t);
|
||||
track.keys.splice(i < 0 ? track.keys.length : i, 0, k);
|
||||
}
|
||||
return k;
|
||||
}
|
||||
const sortKeys = (tr) => tr.keys.sort((a, b) => a.t - b.t);
|
||||
const allTimes = (c) => [...new Set([0, Math.round(c.total), ...c.tracks.flatMap((t) => t.keys.map((k) => Math.round(k.t)))])].sort((a, b) => a - b);
|
||||
|
||||
/** Drops keys that the neighbours already give (within the tolerances). Keeps first / last. */
|
||||
function thin(keys, rotTol = 0.15 * (Math.PI / 180), posTol = 0.02) {
|
||||
if (keys.length <= 2) {
|
||||
if (keys.length === 2 && Q.angle(keys[0].q, keys[1].q) < rotTol && V.len(V.sub(keys[0].p, keys[1].p)) < posTol) return [keys[0]];
|
||||
return keys;
|
||||
}
|
||||
const out = [keys[0]];
|
||||
let a = 0;
|
||||
for (let i = 1; i < keys.length - 1; i++) {
|
||||
// can a → i+1 stand in for every key between them?
|
||||
const A = keys[a], B = keys[i + 1];
|
||||
let ok = true;
|
||||
for (let j = a + 1; j <= i && ok; j++) {
|
||||
const f = (keys[j].t - A.t) / Math.max(1e-6, B.t - A.t);
|
||||
ok = Q.angle(Q.slerp(A.q, B.q, f), keys[j].q) < rotTol && V.len(V.sub(V.lerp(A.p, B.p, f), keys[j].p)) < posTol;
|
||||
}
|
||||
if (!ok) {
|
||||
out.push(keys[i]);
|
||||
a = i;
|
||||
}
|
||||
}
|
||||
const last = keys[keys.length - 1];
|
||||
if (out.length === 1 && Q.angle(out[0].q, last.q) < rotTol && V.len(V.sub(out[0].p, last.p)) < posTol) return out;
|
||||
out.push(last);
|
||||
return out;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Lost Saga skeleton posing (SkinKit.prepare / link'ed skeleton + a clip)
|
||||
// =====================================================================
|
||||
function trackMap(sk, clip) {
|
||||
return clip ? clip.tracks.map((t) => sk.byName.get(nameOf(t.name).toLowerCase()) ?? -1) : [];
|
||||
}
|
||||
/** Local position / rotation of every bone at t (track positions × the link rate, like the client). */
|
||||
function locals(sk, clip, map, t, override) {
|
||||
const bones = sk.data.bones;
|
||||
const lp = bones.map((b) => b.localPos.slice());
|
||||
const lr = bones.map((b) => b.localRot.slice());
|
||||
if (clip)
|
||||
clip.tracks.forEach((tr, ti) => {
|
||||
const bi = map[ti];
|
||||
if (bi < 0 || tr.weight <= 0) return;
|
||||
const s = sample(tr.keys, t);
|
||||
if (!s) return;
|
||||
const r = sk.rate[bi];
|
||||
lp[bi] = [s.p[0] * r, s.p[1] * r, s.p[2] * r];
|
||||
lr[bi] = s.q;
|
||||
});
|
||||
if (override) for (const [bi, o] of override) {
|
||||
if (o.p) lp[bi] = V.scale(o.p, sk.rate[bi]);
|
||||
if (o.q) lr[bi] = o.q;
|
||||
}
|
||||
return { lp, lr };
|
||||
}
|
||||
function world(sk, lp, lr) {
|
||||
const n = lp.length;
|
||||
const wp = new Array(n), wr = new Array(n);
|
||||
for (const i of sk.order) {
|
||||
const pi = sk.parent[i];
|
||||
if (pi < 0) {
|
||||
wr[i] = lr[i];
|
||||
wp[i] = lp[i];
|
||||
} else {
|
||||
wr[i] = Q.mul(wr[pi], lr[i]);
|
||||
wp[i] = V.add(wp[pi], Q.rot(wr[pi], lp[i]));
|
||||
}
|
||||
}
|
||||
return { wp, wr };
|
||||
}
|
||||
function lsPose(sk, clip, map, t, override) {
|
||||
const l = locals(sk, clip, map, t, override);
|
||||
return Object.assign(world(sk, l.lp, l.lr), l);
|
||||
}
|
||||
|
||||
/** A Lost Saga skeleton + clip as a retarget source. */
|
||||
function lsRig(sk, clip) {
|
||||
const map = trackMap(sk, clip);
|
||||
const rest = lsPose(sk, null, null, 0);
|
||||
return {
|
||||
kind: "ls",
|
||||
names: sk.data.bones.map((b) => b.name.text),
|
||||
parent: sk.parent,
|
||||
restWp: rest.wp,
|
||||
restWr: rest.wr,
|
||||
restLp: sk.data.bones.map((b) => b.localPos.slice()),
|
||||
total: clip.total,
|
||||
times: allTimes(clip),
|
||||
events: clip.events,
|
||||
pose: (t) => lsPose(sk, clip, map, t),
|
||||
};
|
||||
}
|
||||
|
||||
/** Knee position of a two-bone chain from H reaching T (lengths a, b), bending towards pole. */
|
||||
function twoBone(H, T, a, b, pole) {
|
||||
const d0 = V.sub(T, H);
|
||||
let d = V.len(d0);
|
||||
const u = d > 1e-6 ? V.scale(d0, 1 / d) : [0, -1, 0];
|
||||
d = Math.max(Math.abs(a - b) + 1e-4, Math.min(a + b - 1e-4, d));
|
||||
const cosA = (a * a + d * d - b * b) / (2 * a * d);
|
||||
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
|
||||
let nrm = V.sub(pole, V.scale(u, V.dot(pole, u)));
|
||||
if (V.len(nrm) < 1e-6) nrm = Math.abs(u[1]) < 0.9 ? V.cross(u, [0, 1, 0]) : [0, 0, -1];
|
||||
nrm = V.norm(nrm);
|
||||
return V.add(H, V.add(V.scale(u, a * cosA), V.scale(nrm, a * sinA)));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// retarget: rig → Lost Saga skeleton
|
||||
// =====================================================================
|
||||
/** Which child a bone points at, for aligning rest poses (first match wins). */
|
||||
const AIM = [/ head$/, / neck$/, / spine\d?$/, / calf$/, / foot$/, / toe0$/, / forearm$/, / hand$/, / upperarm$/, / finger2$/, / finger\d1$/, / finger\d2$/];
|
||||
|
||||
/**
|
||||
* Default bone map target bone index → source bone index (-1 = keeps its rest local rotation).
|
||||
* Names are matched case-insensitively.
|
||||
*/
|
||||
function defaultMap(src, sk) {
|
||||
const names = sk.data.bones.map((b) => b.name.text);
|
||||
const by = new Map(src.names.map((n, i) => [n.toLowerCase(), i]));
|
||||
return names.map((n) => by.get(n.toLowerCase()) ?? -1);
|
||||
}
|
||||
|
||||
function legChain(sk, side) {
|
||||
const b = (n) => sk.byName.get(`bip01 ${side} ${n}`) ?? -1;
|
||||
const c = { hip: b("thigh"), knee: b("calf"), ankle: b("foot"), toe: b("toe0") };
|
||||
return c.hip >= 0 && c.knee >= 0 && c.ankle >= 0 ? c : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bakes src onto the Lost Saga skeleton sk. opts:
|
||||
* map target bone → source bone (defaultMap)
|
||||
* root target root bone (Bip01) / its source bone (the map's)
|
||||
* scale source → target units (default: leg length ratio)
|
||||
* footIK put the ankles where the source's ankles are (no sliding, feet on the floor)
|
||||
* ground (no foot IK) move the root each frame so the lowest foot is as high as the source's
|
||||
* inPlace drop the root's horizontal travel
|
||||
* start/end source range (ms), fps of the baked keys, version, tolerance (degrees) for thinning
|
||||
* → { clip, info }
|
||||
*/
|
||||
function retarget(src, sk, opts = {}) {
|
||||
const map = opts.map || defaultMap(src, sk);
|
||||
const bones = sk.data.bones;
|
||||
const n = bones.length;
|
||||
const rest = lsPose(sk, null, null, 0);
|
||||
const root = sk.byName.get("bip01") ?? sk.order[0];
|
||||
const rootSrc = map[root];
|
||||
if (rootSrc < 0) throw new Error("The root bone (Bip01) has no source bone.");
|
||||
const legs = ["l", "r"].map((s) => legChain(sk, s)).filter(Boolean);
|
||||
// scale: leg length (hip → ankle) of the two rest poses, else heights
|
||||
let scale = opts.scale;
|
||||
if (!scale) {
|
||||
const lens = legs
|
||||
.map((l) => [l, map[l.hip], map[l.knee], map[l.ankle]])
|
||||
.filter(([, a, b, c]) => a >= 0 && b >= 0 && c >= 0)
|
||||
.map(([l, a, b, c]) => {
|
||||
const tl = V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])) + V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee]));
|
||||
const sl = V.len(V.sub(src.restWp[b], src.restWp[a])) + V.len(V.sub(src.restWp[c], src.restWp[b]));
|
||||
return tl / sl;
|
||||
});
|
||||
scale = lens.length ? lens.reduce((a, b) => a + b) / lens.length : rest.wp[root][1] / Math.max(1e-6, src.restWp[rootSrc][1]) || 1;
|
||||
}
|
||||
// rest alignment: A[b] turns the target's rest direction onto the source's rest direction
|
||||
const A = new Array(n).fill(null);
|
||||
const childs = bones.map(() => []);
|
||||
sk.parent.forEach((p, i) => p >= 0 && childs[p].push(i));
|
||||
for (const b of sk.order) {
|
||||
if (map[b] < 0) continue;
|
||||
// feet stand flat in both rest poses: the same posture, nothing to align
|
||||
if (/ (foot|toe\d*)$/.test(bones[b].name.text.toLowerCase())) {
|
||||
A[b] = Q.id();
|
||||
continue;
|
||||
}
|
||||
let aim = -1;
|
||||
for (const re of AIM) {
|
||||
aim = childs[b].find((c) => map[c] >= 0 && re.test(bones[c].name.text.toLowerCase())) ?? -1;
|
||||
if (aim >= 0) break;
|
||||
}
|
||||
if (aim >= 0) {
|
||||
const dt = V.sub(rest.wp[aim], rest.wp[b]);
|
||||
const ds = V.sub(src.restWp[map[aim]], src.restWp[map[b]]);
|
||||
// the source's rest direction is in its own rest frame; compare them in world
|
||||
if (V.len(dt) > 1e-4 && V.len(ds) > 1e-4) A[b] = Q.fromTo(dt, ds);
|
||||
}
|
||||
if (!A[b]) {
|
||||
// finger tips, the toe, the head: the alignment of the nearest aligned parent
|
||||
let p = sk.parent[b];
|
||||
while (p >= 0 && !A[p]) p = sk.parent[p];
|
||||
A[b] = p >= 0 && /finger|toe/.test(bones[b].name.text.toLowerCase()) ? A[p] : Q.id();
|
||||
}
|
||||
}
|
||||
if (opts.alignRoot === false) A[root] = Q.id();
|
||||
const restLR = bones.map((b) => b.localRot.slice());
|
||||
const restLP = bones.map((b) => b.localPos.slice());
|
||||
|
||||
// local positions: the skeleton's own, or (Lost Saga sources) the source's, scaled to the bone
|
||||
const lpOf = (b, S) => {
|
||||
const m = map[b];
|
||||
if (src.kind !== "ls" || m < 0 || !S.lp) return restLP[b];
|
||||
const l = V.len(src.restLp[m]);
|
||||
return V.scale(S.lp[m], l > 1e-6 ? V.len(restLP[b]) / l : 1);
|
||||
};
|
||||
/** Target world rotations + positions for a source pose; rootPos placed by the caller. */
|
||||
function solve(S, rootPos) {
|
||||
const wr = new Array(n), wp = new Array(n);
|
||||
for (const b of sk.order) {
|
||||
const pi = sk.parent[b];
|
||||
const m = map[b];
|
||||
if (m >= 0) {
|
||||
const delta = Q.mul(S.wr[m], Q.conj(src.restWr[m]));
|
||||
wr[b] = Q.norm(Q.mul(delta, Q.mul(A[b], rest.wr[b])));
|
||||
} else wr[b] = pi < 0 ? restLR[b] : Q.mul(wr[pi], restLR[b]);
|
||||
const lp = b === root ? rootPos : lpOf(b, S);
|
||||
wp[b] = pi < 0 ? lp : V.add(wp[pi], Q.rot(wr[pi], lp));
|
||||
}
|
||||
return { wr, wp, S };
|
||||
}
|
||||
// where the target root sits for a source pose: its rest place + the scaled source travel, lifted
|
||||
// so the aligned rest pose's feet stand where the target's rest feet do
|
||||
const srcRest = { wr: src.restWr, wp: src.restWp };
|
||||
const aligned = solve(srcRest, rest.wp[root]);
|
||||
const footBones = legs.map((l) => l.ankle);
|
||||
const lift = footBones.length ? footBones.reduce((s, b) => s + rest.wp[b][1] - aligned.wp[b][1], 0) / footBones.length : 0;
|
||||
const restRoot = V.add(rest.wp[root], [0, lift, 0]);
|
||||
const restAligned = solve(srcRest, restRoot);
|
||||
// the body moves with the source's body joint (rootPos)
|
||||
const posSrc = src.rootPos >= 0 ? src.rootPos : rootSrc;
|
||||
const place = (p) => V.add(restRoot, V.scale(V.sub(p, src.restWp[posSrc]), scale));
|
||||
// where each target ankle goes: the source ankle's travel from rest, scaled, from the aligned rest
|
||||
const ikLegs = legs
|
||||
.filter((l) => map[l.ankle] >= 0)
|
||||
.map((l) => ({ ...l, a: V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])), b: V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee])), src: map[l.ankle], srcToe: l.toe >= 0 ? map[l.toe] : -1 }));
|
||||
const footLift = (P, l, ref) => Math.min(P.wp[l.ankle][1] - ref.wp[l.ankle][1], l.toe >= 0 ? P.wp[l.toe][1] - ref.wp[l.toe][1] : Infinity);
|
||||
|
||||
const start = Math.max(0, opts.start ?? 0);
|
||||
const end = Math.min(src.total, opts.end ?? src.total);
|
||||
const fps = opts.fps || 30;
|
||||
const times = [];
|
||||
for (let t = start; t < end - 1e-6; t += 1000 / fps) times.push(t);
|
||||
times.push(end);
|
||||
const keys = bones.map(() => []);
|
||||
let dropped = 0, maxDrop = 0, jumped = 0;
|
||||
const jump = opts.jump === "on" || opts.jump === true;
|
||||
for (const ts of times) {
|
||||
const S = src.pose(ts);
|
||||
let rp = place(S.wp[posSrc]);
|
||||
// jump: both source feet off the floor while the source body stays put → the body goes up
|
||||
// by the lower ankle's height (the feet stay where they are, the legs unfold to standing)
|
||||
// jumpScale > 1 also lifts the feet: the whole jump gets higher
|
||||
let extraY = 0;
|
||||
if (jump && ikLegs.length) {
|
||||
const up = Math.min(...ikLegs.map((l) => S.wp[l.src][1] - src.restWp[l.src][1]));
|
||||
const dz = 0.15; // source units: heels / tiptoes aren't a jump
|
||||
if (up > dz) {
|
||||
extraY = (up - dz) * scale * Math.max(0, (opts.jumpScale ?? 1) - 1);
|
||||
rp = V.add(rp, [0, (up - dz) * scale + extraY, 0]);
|
||||
jumped++;
|
||||
}
|
||||
}
|
||||
// in place: the root's travel over the floor comes off the root and the feet alike
|
||||
const travel = opts.inPlace ? [rp[0] - restRoot[0], 0, rp[2] - restRoot[2]] : [0, 0, 0];
|
||||
rp = V.sub(rp, travel);
|
||||
let P = solve(S, rp);
|
||||
if (opts.footIK !== false && ikLegs.length) {
|
||||
const targets = ikLegs.map((l) => V.add(V.sub(V.add(restAligned.wp[l.ankle], V.scale(V.sub(S.wp[l.src], src.restWp[l.src]), scale)), travel), [0, extraY, 0]));
|
||||
// a planted foot the leg can't reach: lower the body just enough (else the foot would hang);
|
||||
// feet in the air (kicks) just get a straight leg
|
||||
let drop = 0;
|
||||
ikLegs.forEach((l, i) => {
|
||||
if (targets[i][1] - restAligned.wp[l.ankle][1] > 0.6 * scale) return;
|
||||
const d = V.sub(targets[i], P.wp[l.hip]);
|
||||
const L = (l.a + l.b) * 0.998;
|
||||
const h2 = d[0] * d[0] + d[2] * d[2];
|
||||
if (V.len(d) > L && h2 < L * L) drop = Math.max(drop, Math.min(0.25 * L, -Math.sqrt(L * L - h2) - d[1]));
|
||||
});
|
||||
if (drop > 1e-4) {
|
||||
rp = V.sub(rp, [0, drop, 0]);
|
||||
P = solve(S, rp);
|
||||
dropped++;
|
||||
maxDrop = Math.max(maxDrop, drop);
|
||||
}
|
||||
ikLegs.forEach((l, i) => legIK(P, l, targets[i]));
|
||||
P = refk(P);
|
||||
// toes as high above the floor as the source's (a Lost Saga foot is about twice as long as
|
||||
// the source's, scaled: the same tilt would push its toes through the floor)
|
||||
if (opts.toeFit !== false) {
|
||||
let moved = false;
|
||||
for (const l of ikLegs) {
|
||||
if (l.toe < 0 || l.srcToe < 0) continue;
|
||||
const want = restAligned.wp[l.toe][1] + (S.wp[l.srcToe][1] - src.restWp[l.srcToe][1]) * scale;
|
||||
const A0 = P.wp[l.ankle];
|
||||
const v = V.sub(P.wp[l.toe], A0);
|
||||
const len = V.len(v);
|
||||
const flat = Math.hypot(v[0], v[2]);
|
||||
if (len < 1e-4 || flat < 1e-4) continue;
|
||||
const pitch = Math.asin(Math.max(-1, Math.min(1, v[1] / len)));
|
||||
const goal = Math.asin(Math.max(-1, Math.min(1, (want - A0[1]) / len)));
|
||||
if (Math.abs(goal - pitch) < 1e-4) continue;
|
||||
// turn about the horizontal axis across the foot, whichever sign raises / lowers the toe
|
||||
const axis = V.norm(V.cross([v[0], 0, v[2]], [0, 1, 0]));
|
||||
let D = Q.axis(axis, goal - pitch);
|
||||
if (Math.sign(Q.rot(D, v)[1] - v[1]) !== Math.sign(goal - pitch)) D = Q.axis(axis, pitch - goal);
|
||||
P.wr[l.ankle] = Q.norm(Q.mul(D, P.wr[l.ankle]));
|
||||
moved = true;
|
||||
}
|
||||
if (moved) P = refk(P);
|
||||
}
|
||||
} else if (opts.ground !== false && ikLegs.length) {
|
||||
// the lowest foot as high above its rest as the source's lowest foot (scaled)
|
||||
const want = Math.min(...ikLegs.map((l) => (S.wp[l.src][1] - src.restWp[l.src][1]) * scale));
|
||||
const have = Math.min(...ikLegs.map((l) => footLift(P, l, restAligned)));
|
||||
if (isFinite(want) && isFinite(have)) P = solve(S, V.add(rp, [0, want - have, 0]));
|
||||
}
|
||||
const t = Math.round(ts - start);
|
||||
for (let b = 0; b < n; b++) {
|
||||
const pi = sk.parent[b];
|
||||
const q = pi < 0 ? P.wr[b] : Q.norm(Q.mul(Q.conj(P.wr[pi]), P.wr[b]));
|
||||
const lp = b === root ? (pi < 0 ? P.wp[b] : Q.rot(Q.conj(P.wr[pi]), V.sub(P.wp[b], P.wp[pi]))) : lpOf(b, S);
|
||||
keys[b].push(key(t, V.scale(lp, 1 / sk.rate[b]), q));
|
||||
}
|
||||
}
|
||||
// two-bone IK on the target: swing thigh and calf (twist kept), foot keeps its world rotation
|
||||
function legIK(P, l, T) {
|
||||
const H = P.wp[l.hip];
|
||||
let pole = V.sub(P.wp[l.knee], V.lerp(H, P.wp[l.ankle], 0.5));
|
||||
const K = twoBone(H, T, l.a, l.b, V.len(pole) > 1e-4 ? pole : Q.rot(P.wr[root], [0, 0, -1]));
|
||||
const footW = P.wr[l.ankle];
|
||||
const sw1 = Q.fromTo(V.sub(P.wp[l.knee], H), V.sub(K, H));
|
||||
P.wr[l.hip] = Q.norm(Q.mul(sw1, P.wr[l.hip]));
|
||||
const kneeDir = Q.rot(sw1, V.sub(P.wp[l.ankle], P.wp[l.knee]));
|
||||
const A2 = V.add(K, V.scale(V.norm(V.sub(T, K)), l.b));
|
||||
const sw2 = Q.fromTo(kneeDir, V.sub(A2, K));
|
||||
P.wr[l.knee] = Q.norm(Q.mul(sw2, Q.mul(sw1, P.wr[l.knee])));
|
||||
P.wr[l.ankle] = footW;
|
||||
// the foot's children (toe) keep their world rotation relative to the foot: nothing to do,
|
||||
// their local rotations are taken from the world ones below
|
||||
}
|
||||
// world rotations after IK → positions again
|
||||
function refk(P) {
|
||||
const wp = new Array(n);
|
||||
for (const b of sk.order) {
|
||||
const pi = sk.parent[b];
|
||||
wp[b] = pi < 0 ? P.wp[b] : V.add(wp[pi], Q.rot(P.wr[pi], lpOf(b, P.S)));
|
||||
}
|
||||
return { wr: P.wr, wp, S: P.S };
|
||||
}
|
||||
|
||||
const tol = (opts.tolerance ?? 0.15) * (Math.PI / 180);
|
||||
const clip = emptyClip(opts.name || "retarget", opts.version || 4002, Math.round(end - start));
|
||||
for (let b = 0; b < n; b++) {
|
||||
const k = thin(keys[b], tol, 0.02);
|
||||
clip.tracks.push({ name: S(bones[b].name.text), weight: 1, keys: k });
|
||||
}
|
||||
clip.events = (src.events || []).filter((e) => e.time >= start && e.time <= end).map((e) => ({ ...e, time: e.time - start }));
|
||||
const mapped = map.filter((m) => m >= 0).length;
|
||||
return { clip, info: { scale, mapped, bones: n, frames: times.length, keys: clip.tracks.reduce((s, t) => s + t.keys.length, 0), lift, restRoot, srcRoot: src.restWp[posSrc], dropped, maxDrop, jump, jumped } };
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// clip operations (range in ms; keys outside keep their stored bytes)
|
||||
// =====================================================================
|
||||
/** Makes sure every track has a key exactly at t (sampled), so a cut there keeps the shape. */
|
||||
function splitAt(c, t) {
|
||||
t = Math.round(t);
|
||||
for (const tr of c.tracks) {
|
||||
if (!tr.keys.length || tr.keys.some((k) => Math.abs(k.t - t) < 0.5)) continue;
|
||||
if (t <= tr.keys[0].t || t >= tr.keys[tr.keys.length - 1].t) continue;
|
||||
const s = sample(tr.keys, t);
|
||||
tr.keys.push(key(t, s.p, s.q));
|
||||
sortKeys(tr);
|
||||
}
|
||||
}
|
||||
const evShift = (c, fn) => (c.events = c.events.map((e) => ({ ...e, time: fn(e.time) })).filter((e) => e.time !== null));
|
||||
|
||||
/** Keeps [a, b] only, starting at 0. */
|
||||
function trim(c, a, b) {
|
||||
for (const tr of c.tracks) {
|
||||
if (!tr.keys.length) continue;
|
||||
const sa = sample(tr.keys, a), sb = sample(tr.keys, b);
|
||||
const mid = tr.keys.filter((k) => k.t > a + 0.5 && k.t < b - 0.5).map((k) => copyKey(k, k.t - a));
|
||||
const ka = tr.keys.find((k) => Math.abs(k.t - a) < 0.5);
|
||||
const kb = tr.keys.find((k) => Math.abs(k.t - b) < 0.5);
|
||||
tr.keys = [ka ? copyKey(ka, 0) : key(0, sa.p, sa.q), ...mid];
|
||||
if (b - a > 0.5) tr.keys.push(kb ? copyKey(kb, b - a) : key(Math.round(b - a), sb.p, sb.q));
|
||||
if (tr.keys.length === 2 && tr.keys[0].raw && tr.keys[1].raw && tr.keys[0].raw.join() === tr.keys[1].raw.join() && V.len(V.sub(tr.keys[0].p, tr.keys[1].p)) < 1e-6) tr.keys.pop();
|
||||
}
|
||||
evShift(c, (t) => (t >= a && t <= b ? t - a : null));
|
||||
c.total = Math.round(b - a);
|
||||
}
|
||||
|
||||
/** Removes [a, b]; what was after b follows a straight away. */
|
||||
function cut(c, a, b) {
|
||||
splitAt(c, a);
|
||||
splitAt(c, b);
|
||||
const d = b - a;
|
||||
for (const tr of c.tracks) {
|
||||
const keep = tr.keys.filter((k) => k.t <= a + 0.5 || k.t >= b - 0.5);
|
||||
// two keys at a (the one at a and the one from b): keep the later pose
|
||||
tr.keys = keep.map((k) => (k.t >= b - 0.5 ? copyKey(k, Math.round(k.t - d)) : k)).filter((k, i, arr) => !(Math.abs(k.t - a) < 0.5 && arr.some((o, j) => j > i && Math.abs(o.t - a) < 0.5)));
|
||||
sortKeys(tr);
|
||||
}
|
||||
evShift(c, (t) => (t < a ? t : t > b ? t - d : null));
|
||||
c.total = Math.round(c.total - d);
|
||||
}
|
||||
|
||||
/** Plays [a, b] at `speed` × (2 = twice as fast); later keys move with it. */
|
||||
function speed(c, a, b, s) {
|
||||
splitAt(c, a);
|
||||
splitAt(c, b);
|
||||
const d = (b - a) / s - (b - a);
|
||||
const map = (t) => (t <= a ? t : t <= b ? a + (t - a) / s : t + d);
|
||||
for (const tr of c.tracks) {
|
||||
tr.keys = tr.keys.map((k) => (k.t > a ? copyKey(k, Math.round(map(k.t))) : k));
|
||||
sortKeys(tr);
|
||||
}
|
||||
evShift(c, map);
|
||||
c.total = Math.round(c.total + d);
|
||||
}
|
||||
|
||||
/** Plays [a, b] backwards. */
|
||||
function reverse(c, a, b) {
|
||||
splitAt(c, a);
|
||||
splitAt(c, b);
|
||||
for (const tr of c.tracks) {
|
||||
tr.keys = tr.keys.map((k) => (k.t >= a - 0.5 && k.t <= b + 0.5 ? copyKey(k, Math.round(a + b - k.t)) : k));
|
||||
sortKeys(tr);
|
||||
}
|
||||
evShift(c, (t) => (t >= a && t <= b ? a + b - t : t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts clip `o` in at time `at` (append: at = c.total), `gap` ms to blend into it and out of it.
|
||||
* Bones only one of them moves hold their pose (or the skeleton's rest, `bind(name)` → {p, q}).
|
||||
*/
|
||||
function insert(c, o, at, gap = 0, bind = () => null) {
|
||||
at = Math.round(Math.max(0, Math.min(c.total, at)));
|
||||
splitAt(c, at);
|
||||
const len = Math.round(o.total + 2 * gap);
|
||||
const names = new Map(c.tracks.map((t) => [nameOf(t.name).toLowerCase(), t]));
|
||||
for (const tr of c.tracks) {
|
||||
const ot = o.tracks.find((x) => nameOf(x.name).toLowerCase() === nameOf(tr.name).toLowerCase());
|
||||
const before = tr.keys.filter((k) => k.t <= at + 0.5);
|
||||
const after = tr.keys.filter((k) => k.t > at + 0.5).map((k) => copyKey(k, k.t + len));
|
||||
let mid;
|
||||
if (ot?.keys.length) mid = ot.keys.map((k) => copyKey(k, k.t + at + gap));
|
||||
else {
|
||||
const s = sample(tr.keys, at);
|
||||
mid = s ? [key(at + gap, s.p, s.q), key(at + gap + Math.round(o.total), s.p, s.q)] : [];
|
||||
}
|
||||
if (gap === 0 && before.length && mid.length && Math.abs(before[before.length - 1].t - mid[0].t) < 0.5) before.pop();
|
||||
tr.keys = [...before, ...mid, ...after];
|
||||
if (!after.length && at < c.total) {
|
||||
const s = sample(before, at);
|
||||
if (s) tr.keys.push(key(at + len, s.p, s.q));
|
||||
}
|
||||
sortKeys(tr);
|
||||
}
|
||||
for (const ot of o.tracks) {
|
||||
if (names.has(nameOf(ot.name).toLowerCase()) || !ot.keys.length) continue;
|
||||
const b = bind(nameOf(ot.name)) || { p: ot.keys[0].p, q: ot.keys[0].q };
|
||||
const keys = [];
|
||||
if (at + gap > 0) keys.push(key(0, b.p, b.q), key(at, b.p, b.q));
|
||||
keys.push(...ot.keys.map((k) => copyKey(k, k.t + at + gap)));
|
||||
if (at < c.total) keys.push(key(at + len, b.p, b.q), key(c.total + len, b.p, b.q));
|
||||
c.tracks.push({ name: ot.name, weight: ot.weight, keys: keys.filter((k, i, a) => !a.slice(i + 1).some((x) => Math.abs(x.t - k.t) < 0.5)) });
|
||||
}
|
||||
evShift(c, (t) => (t <= at ? t : t + len));
|
||||
c.events.push(...o.events.map((e) => ({ ...e, time: e.time + at + gap })));
|
||||
c.total = Math.round(c.total + len);
|
||||
}
|
||||
|
||||
/** Last pose = first pose on every track (a key at the end, the first key's stored bytes). */
|
||||
function makeLoop(c) {
|
||||
const end = Math.round(c.total);
|
||||
for (const tr of c.tracks) {
|
||||
if (tr.keys.length < 2) continue;
|
||||
tr.keys = tr.keys.filter((k) => k.t < end - 0.5);
|
||||
tr.keys.push(copyKey(tr.keys[0], end));
|
||||
}
|
||||
}
|
||||
|
||||
/** "Bip01 L Thigh" ↔ "Bip01 R Thigh" (also left/right, _l/_r). */
|
||||
function mirrorName(name) {
|
||||
const swaps = [[/(^|[ _.])L([ _.]|$)/, "$1R$2"], [/(^|[ _.])R([ _.]|$)/, "$1L$2"], [/left/i, "right"], [/right/i, "left"]];
|
||||
for (const [re, to] of swaps) if (re.test(name)) return name.replace(re, to);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Mirrors the whole clip left ↔ right across the plane x = 0 (axis "x") or z = 0 ("z"). */
|
||||
function mirror(c, sk, axis = "x") {
|
||||
const ax = axis === "z" ? 2 : 0;
|
||||
const flip = (v) => v.map((x, i) => (i === ax ? -x : x));
|
||||
const flipQ = (q) => (ax === 0 ? [q[0], -q[1], -q[2], q[3]] : [-q[0], -q[1], q[2], q[3]]);
|
||||
const bones = sk.data.bones;
|
||||
const n = bones.length;
|
||||
const partner = bones.map((b) => sk.byName.get(mirrorName(b.name.text).toLowerCase()) ?? -1).map((p, i) => (p >= 0 ? p : i));
|
||||
const map = trackMap(sk, c);
|
||||
const rest = lsPose(sk, null, null, 0);
|
||||
// W'(b) = M·W(b')·M · (M·Wrest(b')·M)⁻¹ · Wrest(b); positions mirrored
|
||||
const corr = bones.map((_, b) => Q.mul(Q.conj(flipQ(rest.wr[partner[b]])), rest.wr[b]));
|
||||
const times = allTimes(c);
|
||||
const keyed = new Set(map.filter((x) => x >= 0));
|
||||
const want = [...new Set([...keyed, ...[...keyed].map((b) => partner[b])])];
|
||||
const out = new Map(want.map((b) => [b, []]));
|
||||
for (const t of times) {
|
||||
const P = lsPose(sk, c, map, t);
|
||||
const wr = new Array(n), wp = new Array(n);
|
||||
for (let b = 0; b < n; b++) {
|
||||
wr[b] = Q.norm(Q.mul(flipQ(P.wr[partner[b]]), corr[b]));
|
||||
wp[b] = flip(P.wp[partner[b]]);
|
||||
}
|
||||
for (const b of want) {
|
||||
const pi = sk.parent[b];
|
||||
const q = pi < 0 ? wr[b] : Q.norm(Q.mul(Q.conj(wr[pi]), wr[b]));
|
||||
const lp = pi < 0 ? wp[b] : Q.rot(Q.conj(wr[pi]), V.sub(wp[b], wp[pi]));
|
||||
out.get(b).push(key(t, V.scale(lp, 1 / sk.rate[b]), q));
|
||||
}
|
||||
}
|
||||
const weight = new Map(c.tracks.map((t, i) => [map[i], t.weight]));
|
||||
const others = c.tracks.filter((_, i) => map[i] < 0);
|
||||
c.tracks = [...want.map((b) => ({ name: S(bones[b].name.text), weight: weight.get(b) ?? weight.get(partner[b]) ?? 1, keys: thin(out.get(b), 0.05 * (Math.PI / 180), 0.005) })), ...others];
|
||||
c.events = c.events.map((e) => ({ ...e, sub: e.sub }));
|
||||
}
|
||||
|
||||
return { Q, V, S, nameOf, fromLsf, toLsf, emptyClip, key, copyKey, sample, setKey, sortKeys, allTimes, thin, trackMap, locals, world, lsPose, lsRig, retarget, defaultMap, trim, cut, speed, reverse, insert, makeLoop, mirror, mirrorName, splitAt, twoBone };
|
||||
})();
|
||||
if (typeof window !== "undefined") window.AnimKit = AnimKit;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,182 @@
|
||||
"use strict";
|
||||
|
||||
// Texture decoding for the UI Image Set editor: DDS (DXT1/DXT3/DXT5, 32/24/16-bit RGB masks) and
|
||||
// TGA (true-colour, RLE) → ImageData. BMP/PNG/JPG go through the browser (createImageBitmap).
|
||||
|
||||
const TexDecode = (() => {
|
||||
const rgb565 = (c) => [((c >> 11) & 31) * 255 / 31, ((c >> 5) & 63) * 255 / 63, (c & 31) * 255 / 31];
|
||||
|
||||
/** One 4×4 DXT colour block → 16 RGB(A) values into out (at pixel x,y). */
|
||||
function colorBlock(src, o, out, w, h, x, y, dxt1, alphaSrc) {
|
||||
const c0 = src[o] | (src[o + 1] << 8);
|
||||
const c1 = src[o + 2] | (src[o + 3] << 8);
|
||||
const p0 = rgb565(c0);
|
||||
const p1 = rgb565(c1);
|
||||
const pal = [p0, p1];
|
||||
if (!dxt1 || c0 > c1) {
|
||||
pal.push([(2 * p0[0] + p1[0]) / 3, (2 * p0[1] + p1[1]) / 3, (2 * p0[2] + p1[2]) / 3]);
|
||||
pal.push([(p0[0] + 2 * p1[0]) / 3, (p0[1] + 2 * p1[1]) / 3, (p0[2] + 2 * p1[2]) / 3]);
|
||||
} else {
|
||||
pal.push([(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2]);
|
||||
pal.push(null); // transparent black (DXT1 1-bit alpha)
|
||||
}
|
||||
const bits = src[o + 4] | (src[o + 5] << 8) | (src[o + 6] << 16) | (src[o + 7] << 24);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const px = x + (i & 3);
|
||||
const py = y + (i >> 2);
|
||||
if (px >= w || py >= h) continue;
|
||||
const c = pal[(bits >>> (2 * i)) & 3];
|
||||
const d = (py * w + px) * 4;
|
||||
if (!c) {
|
||||
out[d] = out[d + 1] = out[d + 2] = out[d + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
out[d] = c[0];
|
||||
out[d + 1] = c[1];
|
||||
out[d + 2] = c[2];
|
||||
out[d + 3] = alphaSrc ? alphaSrc[i] : 255;
|
||||
}
|
||||
}
|
||||
|
||||
function dxt(src, offset, w, h, kind) {
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
const blockBytes = kind === "DXT1" ? 8 : 16;
|
||||
let o = offset;
|
||||
const alpha = new Uint8Array(16);
|
||||
for (let y = 0; y < h; y += 4) {
|
||||
for (let x = 0; x < w; x += 4) {
|
||||
let a = null;
|
||||
if (kind === "DXT3") {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const b = src[o + i];
|
||||
alpha[i * 2] = (b & 15) * 17;
|
||||
alpha[i * 2 + 1] = (b >> 4) * 17;
|
||||
}
|
||||
a = alpha;
|
||||
} else if (kind === "DXT5") {
|
||||
const a0 = src[o];
|
||||
const a1 = src[o + 1];
|
||||
const pal = [a0, a1];
|
||||
if (a0 > a1) for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7);
|
||||
else {
|
||||
for (let i = 1; i < 5; i++) pal.push(((5 - i) * a0 + i * a1) / 5);
|
||||
pal.push(0, 255);
|
||||
}
|
||||
// 48 bits of 3-bit indices
|
||||
let lo = src[o + 2] | (src[o + 3] << 8) | (src[o + 4] << 16);
|
||||
let hi = src[o + 5] | (src[o + 6] << 8) | (src[o + 7] << 16);
|
||||
for (let i = 0; i < 8; i++) alpha[i] = pal[(lo >> (3 * i)) & 7];
|
||||
for (let i = 0; i < 8; i++) alpha[8 + i] = pal[(hi >> (3 * i)) & 7];
|
||||
a = alpha;
|
||||
}
|
||||
colorBlock(src, o + (kind === "DXT1" ? 0 : 8), out, w, h, x, y, kind === "DXT1", a);
|
||||
o += blockBytes;
|
||||
}
|
||||
}
|
||||
return new ImageData(out, w, h);
|
||||
}
|
||||
|
||||
const shiftOf = (mask) => {
|
||||
if (!mask) return [0, 0];
|
||||
let s = 0;
|
||||
while (!((mask >>> s) & 1)) s++;
|
||||
let n = 0;
|
||||
while ((mask >>> (s + n)) & 1) n++;
|
||||
return [s, n];
|
||||
};
|
||||
|
||||
function masked(src, offset, w, h, bpp, masks, hasAlpha) {
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
const bytes = bpp / 8;
|
||||
const pitch = w * bytes;
|
||||
const chans = masks.map(shiftOf);
|
||||
const dv = new DataView(src.buffer, src.byteOffset, src.byteLength);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const o = offset + y * pitch + x * bytes;
|
||||
const v = bytes === 4 ? dv.getUint32(o, true) : bytes === 3 ? src[o] | (src[o + 1] << 8) | (src[o + 2] << 16) : dv.getUint16(o, true);
|
||||
const d = (y * w + x) * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const [s, n] = chans[c];
|
||||
out[d + c] = n ? (((v >>> s) & ((1 << n) - 1)) * 255) / ((1 << n) - 1) : 0;
|
||||
}
|
||||
const [as, an] = chans[3];
|
||||
out[d + 3] = hasAlpha && an ? (((v >>> as) & ((1 << an) - 1)) * 255) / ((1 << an) - 1) : 255;
|
||||
}
|
||||
}
|
||||
return new ImageData(out, w, h);
|
||||
}
|
||||
|
||||
function dds(bytes) {
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (dv.getUint32(0, true) !== 0x20534444) throw new Error("Not a DDS file");
|
||||
const h = dv.getUint32(12, true);
|
||||
const w = dv.getUint32(16, true);
|
||||
const pf = dv.getUint32(80, true);
|
||||
const fourCC = String.fromCharCode(bytes[84], bytes[85], bytes[86], bytes[87]);
|
||||
const offset = 128;
|
||||
if (pf & 4) {
|
||||
if (fourCC === "DXT1" || fourCC === "DXT3" || fourCC === "DXT5") return dxt(bytes, offset, w, h, fourCC);
|
||||
if (fourCC === "DXT2" || fourCC === "DXT4") return dxt(bytes, offset, w, h, fourCC === "DXT2" ? "DXT3" : "DXT5");
|
||||
throw new Error(`DDS format ${fourCC} isn't supported`);
|
||||
}
|
||||
const bpp = dv.getUint32(88, true);
|
||||
const masks = [dv.getUint32(92, true), dv.getUint32(96, true), dv.getUint32(100, true), dv.getUint32(104, true)];
|
||||
return masked(bytes, offset, w, h, bpp, masks, !!(pf & 1));
|
||||
}
|
||||
|
||||
function tga(bytes) {
|
||||
const idLen = bytes[0];
|
||||
const type = bytes[2];
|
||||
const w = bytes[12] | (bytes[13] << 8);
|
||||
const h = bytes[14] | (bytes[15] << 8);
|
||||
const bpp = bytes[16];
|
||||
const topDown = (bytes[17] & 0x20) !== 0;
|
||||
if (![2, 10].includes(type) || ![24, 32].includes(bpp)) throw new Error("Only true-colour TGA (24/32-bit) is supported");
|
||||
const px = bpp / 8;
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
let o = 18 + idLen;
|
||||
let i = 0;
|
||||
const put = (b, g, r, a) => {
|
||||
const x = i % w;
|
||||
const y = Math.floor(i / w);
|
||||
const d = ((topDown ? y : h - 1 - y) * w + x) * 4;
|
||||
out[d] = r;
|
||||
out[d + 1] = g;
|
||||
out[d + 2] = b;
|
||||
out[d + 3] = a;
|
||||
i++;
|
||||
};
|
||||
while (i < w * h) {
|
||||
if (type === 2) {
|
||||
put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255);
|
||||
o += px;
|
||||
continue;
|
||||
}
|
||||
const hdr = bytes[o++];
|
||||
const n = (hdr & 127) + 1;
|
||||
if (hdr & 128) {
|
||||
const c = [bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255];
|
||||
o += px;
|
||||
for (let k = 0; k < n; k++) put(...c);
|
||||
} else {
|
||||
for (let k = 0; k < n; k++) {
|
||||
put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255);
|
||||
o += px;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ImageData(out, w, h);
|
||||
}
|
||||
|
||||
/** Any supported texture → ImageBitmap. */
|
||||
async function decode(bytes, ext) {
|
||||
const e = (ext || "").toLowerCase();
|
||||
if (e === "dds") return createImageBitmap(dds(bytes));
|
||||
if (e === "tga") return createImageBitmap(tga(bytes));
|
||||
return createImageBitmap(new Blob([bytes]));
|
||||
}
|
||||
|
||||
return { decode, dds, tga };
|
||||
})();
|
||||
window.TexDecode = TexDecode;
|
||||
@@ -0,0 +1,547 @@
|
||||
"use strict";
|
||||
|
||||
// The script is the source of truth. Property edits replace only their own line so comments,
|
||||
// unknown keys, model/light blocks, and the original byte encoding survive a round trip.
|
||||
(() => {
|
||||
const FXE = { src: null, rows: [], where: null, name: "", text: "", saved: "", selected: null, playing: true };
|
||||
window.FXE = FXE;
|
||||
let view = null, kit = null, inst = null, last = 0, loadSeq = 0, modelLoadSeq = 0;
|
||||
const textures = new Map();
|
||||
let modelViews = [];
|
||||
const bytesToRaw = (bytes) => {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192));
|
||||
return out;
|
||||
};
|
||||
const rawToBytes = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255);
|
||||
const ending = () => FXE.text.includes("\r\n") ? "\r\n" : FXE.text.includes("\r") ? "\r" : "\n";
|
||||
const field = (key, label, type = "text", options = []) => ({ key, label, type, options });
|
||||
const commonSystem = [
|
||||
field("quota", "Max particles", "number"), field("cull_each", "Cull each", "bool"),
|
||||
field("follow_parent", "Follow parent"), field("end_condition", "End condition", "select", ["never_end", "time_limit"]),
|
||||
field("limit_time", "End time (ms)", "number"), field("start_create_particles", "Start count", "number"),
|
||||
field("link_biped_name", "Linked bone"), field("random_sync_index", "Random sync", "number"),
|
||||
];
|
||||
const schema = {
|
||||
info: [
|
||||
["General", field("direction", "Direction", "vec3"), field("collision_box", "Collision box", "text"),
|
||||
field("skeleton_name", "Skeleton"), field("animation_name", "Animation"), field("update_always", "Update always", "bool")],
|
||||
],
|
||||
particle: [
|
||||
["System", ...commonSystem],
|
||||
["Appearance", field("texture", "Texture"), field("default_width", "Width min / max", "vec2"),
|
||||
field("default_height", "Height min / max", "vec2"), field("render_type", "Render type", "select", ["add", "src_alpha", "screen", "lighten", "darken", "subtract", "rev_subtract"]),
|
||||
field("tex_filtering", "Texture filter", "select", ["bilinear", "point", "trilinear"]), field("depth_check", "Depth check", "bool"),
|
||||
field("modulate2x", "Modulate ×2", "bool"), field("billboard_type", "Billboard", "select", ["point", "common", "direction"]),
|
||||
field("common_dir", "Common direction", "vec3"), field("common_dir_none_billboard", "Fixed direction", "bool")],
|
||||
["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")],
|
||||
],
|
||||
model: [
|
||||
["Model", field("mesh_file", "Mesh file"), ...commonSystem],
|
||||
["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")],
|
||||
],
|
||||
emitter: [
|
||||
["Emission", field("emit_pos", "Position", "vec3"), field("emit_dir", "Direction", "vec3"),
|
||||
field("emit_angle", "Angle", "number"), field("emit_rate", "Per second", "number"),
|
||||
field("emit_speed", "Speed min / max", "vec2"), field("emit_live_time", "Life min / max", "vec2"),
|
||||
field("emit_wait_time", "Wait (ms)", "number"), field("emit_start_tex_rotation", "Texture rotation", "vec2"),
|
||||
field("start_rot_axis", "Start rotation axis", "vec3"), field("start_rot_angle", "Start rotation angle", "vec2"),
|
||||
field("area_size", "Area size", "vec3"), field("inner_size", "Inner size", "vec3")],
|
||||
["Curves", field("color_rate", "Colour curve"), field("alpha_rate", "Alpha curve")],
|
||||
],
|
||||
light: [
|
||||
["Light", field("type", "Type", "select", ["point", "spot", "directional"]),
|
||||
field("position", "Position", "vec3"), field("targetpos", "Target", "vec3"), field("direction", "Direction", "vec3"),
|
||||
field("diffuse", "Diffuse", "vec4"), field("specular", "Specular", "vec4"), field("attenuation", "Attenuation", "vec3"),
|
||||
field("spot_theta", "Spot inner", "number"), field("spot_phi", "Spot outer", "number"),
|
||||
field("spot_falloff", "Falloff", "number"), field("light_start", "Start", "number"), field("light_end", "End", "number"),
|
||||
field("light_texture", "Light texture"), field("decal_texture", "Decal texture")],
|
||||
],
|
||||
};
|
||||
const affectorSchema = {
|
||||
linear: [field("force_vector", "Force", "vec3"), field("force_method", "Method"), field("force_rate", "Force curve")],
|
||||
scale: [field("scale_rate", "Scale curve")],
|
||||
texrotation: [field("rot_range", "Rotation range", "vec2"), field("rot_speed", "Speed min / max", "vec2")],
|
||||
rotation: [field("rot_axis", "Axis", "vec3"), field("rot_speed", "Speed min / max", "vec2")],
|
||||
emit: [field("power", "Power", "number"), field("force_rate", "Force curve"), field("change_direction", "Change direction", "bool")],
|
||||
};
|
||||
|
||||
function tree(text) {
|
||||
const lines = text.split(/\r\n|\n|\r/), roots = [], stack = [];
|
||||
let header = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (!t || t.startsWith("//")) continue;
|
||||
if (t === "{" || t.endsWith("{")) {
|
||||
const h = t === "{" ? lines[header]?.trim() || "" : t.slice(0, -1).trim();
|
||||
const [kind = "", ...rest] = h.split(/\s+/);
|
||||
const node = { kind: kind.toLowerCase(), type: rest.join(" "), start: t === "{" ? header : i, open: i, end: -1, children: [], parent: stack.at(-1) || null };
|
||||
if (node.parent) node.parent.children.push(node); else roots.push(node);
|
||||
stack.push(node);
|
||||
} else if (t === "}") {
|
||||
if (!stack.length) throw new Error(`Unexpected } at line ${i + 1}`);
|
||||
stack.pop().end = i;
|
||||
} else if (!stack.length || /^(info|particle|model|light|emitter|affector)\b/i.test(t)) header = i;
|
||||
}
|
||||
if (stack.length) throw new Error(`Missing } for ${stack.at(-1).kind}`);
|
||||
return { lines, roots };
|
||||
}
|
||||
const flatten = (nodes) => nodes.flatMap((n) => [n, ...flatten(n.children)]);
|
||||
const nodeAt = (parsed, start) => flatten(parsed.roots).find((n) => n.start === start);
|
||||
function attrs(parsed, node) {
|
||||
const nested = node.children.map((n) => [n.start, n.end]);
|
||||
const out = [];
|
||||
for (let i = node.open + 1; i < node.end; i++) {
|
||||
if (nested.some(([a, b]) => i >= a && i <= b)) continue;
|
||||
const m = /^\s*([\w-]+)(?:\s+(.*))?$/.exec(parsed.lines[i]);
|
||||
if (m && !m[1].startsWith("//")) out.push({ line: i, key: m[1], value: m[2] || "" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function setText(text, selection = FXE.selected) {
|
||||
FXE.text = text;
|
||||
if ($("fx-script").value !== text) $("fx-script").value = text;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = tree(text);
|
||||
$("fx-parse-status").textContent = `${parsed.roots.length} blocks`;
|
||||
} catch (err) {
|
||||
$("fx-parse-status").textContent = err.message;
|
||||
parsed = null;
|
||||
}
|
||||
FXE.parsed = parsed;
|
||||
FXE.selected = parsed && nodeAt(parsed, selection) ? selection : parsed?.roots[0]?.start ?? null;
|
||||
renderHierarchy();
|
||||
renderProperties();
|
||||
refreshPreview();
|
||||
const dirty = text !== FXE.saved;
|
||||
$("fx-dirty").hidden = !dirty;
|
||||
$("fx-save").disabled = !dirty;
|
||||
$("fx-save-as").disabled = !text;
|
||||
$("fx-title").textContent = FXE.name || "Untitled";
|
||||
}
|
||||
function renderHierarchy() {
|
||||
const box = $("fx-hierarchy");
|
||||
if (!FXE.parsed) return box.replaceChildren(el("small", { class: "muted", text: "Fix the script braces to inspect blocks." }));
|
||||
const rows = [];
|
||||
const totals = { particle: 0, model: 0 };
|
||||
const walk = (nodes, depth) => nodes.forEach((n) => {
|
||||
const label = n.kind === "particle" || n.kind === "model" ? `${n.kind === "model" ? "Model" : "Particle"} ${totals[n.kind]++}` : n.kind === "emitter" || n.kind === "affector" ? `${n.kind} ${n.type}` : n.kind;
|
||||
rows.push(el("button", { class: `fx-node${FXE.selected === n.start ? " on" : ""}`, style: `padding-left:${8 + depth * 16}px`, text: label, onclick: () => { FXE.selected = n.start; renderHierarchy(); renderProperties(); } }));
|
||||
walk(n.children, depth + 1);
|
||||
});
|
||||
walk(FXE.parsed.roots, 0);
|
||||
box.replaceChildren(...rows);
|
||||
}
|
||||
function setField(node, key, value) {
|
||||
const parsed = tree(FXE.text), current = nodeAt(parsed, node.start);
|
||||
if (!current) return;
|
||||
const lines = parsed.lines;
|
||||
const a = attrs(parsed, current).find((x) => x.key.toLowerCase() === key.toLowerCase());
|
||||
if (a) {
|
||||
const indent = /^\s*/.exec(lines[a.line])[0];
|
||||
lines[a.line] = `${indent}${a.key}${value ? ` ${value}` : ""}`;
|
||||
} else {
|
||||
const first = attrs(parsed, current)[0];
|
||||
const indent = first ? /^\s*/.exec(lines[first.line])[0] : /^\s*/.exec(lines[current.open])[0] + "\t";
|
||||
const at = current.children[0]?.start ?? current.end;
|
||||
lines.splice(at, 0, `${indent}${key}${value ? ` ${value}` : ""}`);
|
||||
}
|
||||
setText(lines.join(ending()), current.start);
|
||||
}
|
||||
function setBlockType(node, type) {
|
||||
const parsed = tree(FXE.text), lines = parsed.lines;
|
||||
const line = lines[node.start];
|
||||
lines[node.start] = line.replace(/^(\s*(?:emitter|affector)\s+)\S+/i, `$1${type}`);
|
||||
setText(lines.join(ending()), node.start);
|
||||
}
|
||||
async function browseResource(node, key, current) {
|
||||
if (!FXE.src) return toast("Choose an effect source first.", true);
|
||||
const mesh = key === "mesh_file";
|
||||
let names;
|
||||
try { names = await call("mdl:names", FXE.src, mesh ? "mesh" : "texture"); }
|
||||
catch (err) { return toast(err.message, true); }
|
||||
const list = el("div", { class: "fx-resource-list" });
|
||||
const search = el("input", { type: "search", placeholder: mesh ? "Search meshes" : "Search textures", spellcheck: "false" });
|
||||
let chosen = current;
|
||||
const shown = el("small", { class: "muted" });
|
||||
const render = () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const matches = names.filter((n) => n.toLowerCase().includes(q));
|
||||
shown.textContent = `${matches.length.toLocaleString()} matches · showing first 100`;
|
||||
list.replaceChildren(...matches.slice(0, 100).map((name) => {
|
||||
const value = mesh ? name.replace(/\.msh$/i, "") : name;
|
||||
return el("button", { class: `fx-resource${value.toLowerCase() === chosen.toLowerCase() ? " on" : ""}`, text: name,
|
||||
onclick: () => { chosen = value; render(); } });
|
||||
}));
|
||||
};
|
||||
search.oninput = render;
|
||||
render();
|
||||
const ok = await modal(mesh ? "Choose effect mesh" : "Choose effect texture", [search, shown, list],
|
||||
[{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]);
|
||||
if (ok && chosen) setField(node, key, chosen);
|
||||
}
|
||||
function control(node, spec, value) {
|
||||
if (/^vec[234]$/.test(spec.type)) {
|
||||
const size = Number(spec.type.slice(-1)), parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
const wrap = el("div", { class: "fx-vector" });
|
||||
const inputs = Array.from({ length: size }, (_, i) => el("input", {
|
||||
type: "number", step: "any", value: parts[i] || "", placeholder: ["X", "Y", "Z", "W"][i],
|
||||
onchange: () => setField(node, spec.key, inputs.map((x) => x.value || "0").join(" ")),
|
||||
}));
|
||||
wrap.replaceChildren(...inputs);
|
||||
return wrap;
|
||||
}
|
||||
if (spec.type === "bool" || spec.type === "select") {
|
||||
const options = spec.type === "bool" ? ["true", "false"] : spec.options;
|
||||
const values = value && !options.some((x) => x.toLowerCase() === value.toLowerCase()) ? [value, ...options] : options;
|
||||
const select = el("select", { onchange: (e) => setField(node, spec.key, e.target.value) }, [
|
||||
el("option", { value: "", text: "—" }), ...values.map((x) => el("option", { value: x, text: x })),
|
||||
]);
|
||||
select.value = values.find((x) => x.toLowerCase() === value.toLowerCase()) || "";
|
||||
return select;
|
||||
}
|
||||
const input = el("input", { type: spec.type === "number" ? "number" : "text", step: spec.type === "number" ? "any" : undefined,
|
||||
value, spellcheck: "false", onchange: (e) => setField(node, spec.key, e.target.value.trim()) });
|
||||
if (["mesh_file", "texture", "light_texture", "decal_texture"].includes(spec.key)) {
|
||||
return el("div", { class: "fx-resource-field" }, [input, el("button", { class: "iconbtn only", title: "Browse resources", text: "…", onclick: () => browseResource(node, spec.key, input.value.trim()) })]);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
function renderProperties() {
|
||||
const node = FXE.parsed && nodeAt(FXE.parsed, FXE.selected);
|
||||
$("fx-selection").textContent = node ? `${node.kind} ${node.type}` : "";
|
||||
$("fx-add-field").disabled = !node;
|
||||
const system = node?.kind === "particle" || node?.kind === "model" ? node : node?.parent;
|
||||
const hasSystem = system?.kind === "particle" || system?.kind === "model";
|
||||
$("fx-add-emitter").disabled = !hasSystem || system.children.some((x) => x.kind === "emitter");
|
||||
$("fx-add-affector").disabled = !hasSystem;
|
||||
$("fx-duplicate-block").disabled = !node || !["particle", "model"].includes(node.kind);
|
||||
const siblings = node && FXE.parsed ? FXE.parsed.roots.filter((x) => x.kind === node.kind) : [];
|
||||
$("fx-move-up").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) <= 0;
|
||||
$("fx-move-down").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) >= siblings.length - 1;
|
||||
$("fx-delete-block").disabled = !node || node.kind === "info";
|
||||
const box = $("fx-properties");
|
||||
if (!node) return box.replaceChildren(el("span", { class: "muted", text: "Select a block." }));
|
||||
const groups = node.kind === "affector" ? [[`${node.type || "Affector"} settings`, field("wait_time", "Wait (ms)", "number"), ...(affectorSchema[node.type.toLowerCase()] || [])]] : schema[node.kind] || [];
|
||||
const fields = [], existing = attrs(FXE.parsed, node), shown = new Set();
|
||||
if (node.kind === "emitter" || node.kind === "affector") {
|
||||
fields.push(el("label", { text: "Type" }));
|
||||
const options = node.kind === "emitter" ? ["Point", "Box", "Cylinder", "Ellipse", "Ellipsoid", "HollowEllipsoid"] : ["Linear", "Scale", "TexRotation", "Rotation", "Emit"];
|
||||
const values = options.some((x) => x.toLowerCase() === node.type.toLowerCase()) ? options : [node.type, ...options];
|
||||
const select = el("select", { onchange: (e) => setBlockType(node, e.target.value) }, values.map((x) => el("option", { value: x, text: x })));
|
||||
select.value = values.find((x) => x.toLowerCase() === node.type.toLowerCase());
|
||||
fields.push(select);
|
||||
}
|
||||
for (const group of groups) {
|
||||
fields.push(el("div", { class: "fx-group", text: group[0] }));
|
||||
for (const spec of group.slice(1)) {
|
||||
const a = existing.find((x) => x.key.toLowerCase() === spec.key);
|
||||
shown.add(spec.key);
|
||||
fields.push(el("label", { text: spec.label, title: spec.key }));
|
||||
fields.push(control(node, spec, a?.value || ""));
|
||||
}
|
||||
}
|
||||
const other = existing.filter((a) => !shown.has(a.key.toLowerCase()));
|
||||
if (other.length) fields.push(el("div", { class: "fx-group", text: "Other fields" }));
|
||||
for (const a of other) {
|
||||
fields.push(el("label", { text: a.key, title: a.key }));
|
||||
fields.push(control(node, field(a.key, a.key), a.value));
|
||||
}
|
||||
box.replaceChildren(...fields);
|
||||
}
|
||||
function mutateLines(fn, selected = FXE.selected) {
|
||||
if (!FXE.parsed) return;
|
||||
const lines = FXE.parsed.lines.slice();
|
||||
fn(lines);
|
||||
setText(lines.join(ending()), selected);
|
||||
}
|
||||
|
||||
function ensureView() {
|
||||
if (view) return view;
|
||||
try { view = new GL3D.View($("fx-canvas"), { alpha: false }); }
|
||||
catch (err) { toast(`Effect preview unavailable: ${err.message}`, true); return null; }
|
||||
// ioEffectEdit renders directly to an opaque back buffer. Additive DDS
|
||||
// textures carry black RGB around their glow; SRC_ALPHA + ONE leaves that
|
||||
// black unchanged on the gray target without changing the texture data.
|
||||
view.env = { clear: [94 / 255, 94 / 255, 94 / 255] };
|
||||
kit = MeshKit.create(view, () => FXE.src);
|
||||
view.opts.lit = false;
|
||||
view.frame([-180, -40, -180], [180, 220, 180]);
|
||||
drawGrid();
|
||||
return view;
|
||||
}
|
||||
function drawGrid() {
|
||||
if (!view) return;
|
||||
if (!$("fx-grid").classList.contains("on")) return view.setOverlay("fx-grid", null);
|
||||
const a = [];
|
||||
const line = (x, z, xx, zz, c) => a.push(x, 0, z, ...c, xx, 0, zz, ...c);
|
||||
for (let v = -300; v <= 300; v += 50) {
|
||||
const c = v === 0 ? [0.82, 0.69, 0.48, 0.62] : [0.7, 0.7, 0.7, 0.35];
|
||||
line(v, -300, v, 300, c); line(-300, v, 300, v, c);
|
||||
}
|
||||
view.setOverlay("fx-grid", new Float32Array(a));
|
||||
}
|
||||
function clearModels() {
|
||||
modelLoadSeq++;
|
||||
for (const entry of modelViews) for (const id of entry?.slots || []) view?.removeMesh(id);
|
||||
modelViews = [];
|
||||
}
|
||||
async function loadModel(index, spec, seq) {
|
||||
const name = (spec.attrs.mesh_file || "").trim().replace(/\.msh$/i, "");
|
||||
if (!name || name.toLowerCase() === "none" || !FXE.src) return;
|
||||
try {
|
||||
const hit = await kit.readNamed(`${name}.msh`, "mesh", true);
|
||||
if (!hit || seq !== modelLoadSeq) return;
|
||||
const mesh = await kit.loadMesh(hit, name);
|
||||
if (seq !== modelLoadSeq) return view.removeMesh(mesh.id);
|
||||
view.setVisible(mesh.id, false);
|
||||
modelViews[index] = { mesh, slots: [mesh.id] };
|
||||
await kit.loadTextures(mesh);
|
||||
if (seq !== modelLoadSeq) return view.removeMesh(mesh.id);
|
||||
syncModels();
|
||||
} catch (err) { if (seq === modelLoadSeq) console.warn(`Effect model ${name}: ${err.message}`); }
|
||||
}
|
||||
function syncModels() {
|
||||
if (!view || !inst) return;
|
||||
inst.modelSystems.forEach((system, index) => {
|
||||
const entry = modelViews[index];
|
||||
if (!entry) return;
|
||||
kit.animate(entry.mesh, system.time / 1000);
|
||||
system.parts.forEach((part, i) => {
|
||||
let id = entry.slots[i];
|
||||
if (!id) {
|
||||
id = `fx-model-${modelLoadSeq}-${index}-${i}`;
|
||||
const m = entry.mesh;
|
||||
view.addMesh(id, { pos: m.data.pos, normal: m.data.normal, uv0: m.data.uv0, uv1: m.data.uv1, indices: m.data.indices, groups: m.groups });
|
||||
entry.slots[i] = id;
|
||||
}
|
||||
view.setTransformQuaternion(id, part.pos, part.modelScale, part.modelRotation);
|
||||
const t = Math.min(1, part.age / part.life), color = system.color(t);
|
||||
view.setTint(id, [color[0] / 255, color[1] / 255, color[2] / 255, Math.max(0, Math.min(1, system.alpha(t)[0]))]);
|
||||
view.setVisible(id, true);
|
||||
});
|
||||
entry.slots.forEach((id, i) => { if (i >= system.parts.length) view.setVisible(id, false); });
|
||||
});
|
||||
}
|
||||
function refreshPreview() {
|
||||
inst = null;
|
||||
clearModels();
|
||||
if (!FXE.text || !ensureView()) return;
|
||||
try { inst = Effects.instance(Effects.parse(FXE.text), [0, 0, 0]); }
|
||||
catch (err) { $("fx-parse-status").textContent = err.message; }
|
||||
view.setParticles([]);
|
||||
if (inst) {
|
||||
const seq = modelLoadSeq;
|
||||
inst.def.models.forEach((spec, index) => loadModel(index, spec, seq));
|
||||
}
|
||||
// Loaded textures stay attached to this WebGL context across script edits.
|
||||
}
|
||||
function texture(name) {
|
||||
if (!name || !FXE.src || !view) return null;
|
||||
const key = name.toLowerCase();
|
||||
if (!textures.has(key)) {
|
||||
textures.set(key, null);
|
||||
kit.texture(name).then((t) => {
|
||||
if (!t.tex) return;
|
||||
const gl = view.gl;
|
||||
gl.bindTexture(gl.TEXTURE_2D, t.tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
textures.set(key, t.tex);
|
||||
});
|
||||
}
|
||||
return textures.get(key);
|
||||
}
|
||||
async function tick(now) {
|
||||
const dt = Math.min(100, Math.max(0, now - (last || now)));
|
||||
last = now;
|
||||
if (view && inst && FXE.playing && $("view-fx").classList.contains("on")) {
|
||||
Effects.update(inst, dt);
|
||||
syncModels();
|
||||
const parts = Effects.build(inst, view.cameraAxes());
|
||||
for (const p of parts) p.tex = texture(p.texture);
|
||||
view.setParticles(parts);
|
||||
$("fx-stats").textContent = `${Effects.count(inst)} particles · ${inst.systems.length} particle systems · ${inst.modelSystems.length} model systems`;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
const list = new VTable($("fx-list"), [{ key: 1, label: "Effect", width: 220, grow: true, title: true }, { key: 2, label: "Pack", width: 75, cls: "mono dim" }], { multi: false, id: "fx" });
|
||||
list.keyOf = (r) => r[0];
|
||||
list.sortBy(1);
|
||||
list.onOpen = (r) => openHit(() => call("mdl:readKey", FXE.src, r[0]), r[1]);
|
||||
function filterList() {
|
||||
const q = $("fx-search").value.trim().toLowerCase();
|
||||
const rows = FXE.rows.filter((r) => r[1].toLowerCase().includes(q));
|
||||
list.setRows(rows);
|
||||
$("fx-count").textContent = rows.length.toLocaleString();
|
||||
}
|
||||
$("fx-search").oninput = filterList;
|
||||
async function loadList() {
|
||||
if (!FXE.src) return;
|
||||
try {
|
||||
const entries = await call("fx:list", FXE.src);
|
||||
FXE.rows = entries.filter(([, path]) => /(^|\/)resource\/effect\/.+\.txt$/i.test(path))
|
||||
.map(([key, path, pack]) => [key, path.replace(/^.*?resource\/effect\//i, ""), pack || "", path]);
|
||||
filterList();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
async function chooseSource() {
|
||||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||||
const options = [...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file })),
|
||||
{ value: "packs", title: "Folder with .iop packs…", sub: "Game client packs" }, { value: "dir", title: "Extracted resource folder…", sub: "Folder containing resource/effect" }];
|
||||
let choice = options[0]?.value;
|
||||
const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [
|
||||
el("input", { type: "radio", name: "fxsrc", checked: i === 0, onchange: () => { choice = o.value; } }),
|
||||
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
|
||||
])));
|
||||
if (!(await modal("Effect source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return;
|
||||
if (choice === "packs" || choice === "dir") {
|
||||
const folder = await call("dialog:folder", choice === "packs" ? "Folder with .iop packs" : "Extracted resources");
|
||||
if (!folder) return;
|
||||
choice = { kind: choice, folder };
|
||||
}
|
||||
try {
|
||||
const d = await call("mdl:describe", choice);
|
||||
FXE.src = choice;
|
||||
$("fx-src-label").textContent = choice.kind === "dir" ? "Extracted folder" : "Packs";
|
||||
$("fx-src").title = d.label;
|
||||
kit?.clearTextures(); textures.clear(); refreshPreview();
|
||||
await loadList();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
$("fx-src").onclick = chooseSource;
|
||||
async function openHit(read, name) {
|
||||
if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return;
|
||||
const seq = ++loadSeq;
|
||||
try {
|
||||
const hit = await read();
|
||||
if (!hit || seq !== loadSeq) return;
|
||||
FXE.where = hit.where; FXE.name = name || hit.source; FXE.saved = bytesToRaw(hit.bytes);
|
||||
FXE.selected = null; setText(FXE.saved);
|
||||
$("fx-empty").hidden = true;
|
||||
view?.resize();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
$("fx-open-file").onclick = async () => {
|
||||
const file = await call("fx:openFile");
|
||||
if (file) await openHit(() => call("mdl:readFile", file), file.replace(/^.*[\\/]/, ""));
|
||||
};
|
||||
$("fx-new").onclick = async () => {
|
||||
if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return;
|
||||
FXE.where = null; FXE.name = "new_effect.txt"; FXE.saved = ""; FXE.selected = null;
|
||||
setText("info\r\n{\r\n}\r\n\r\nparticle\r\n{\r\nquota 100\r\ndefault_width 50 50\r\ndefault_height 50 50\r\nrender_type add\r\ntexture effect.dds\r\nemitter point\r\n{\r\nemit_rate 20\r\nemit_speed 40 80\r\nemit_live_time 1000 1500\r\n}\r\n}\r\n");
|
||||
$("fx-empty").hidden = true;
|
||||
view?.resize();
|
||||
};
|
||||
$("fx-script").oninput = (e) => setText(e.target.value);
|
||||
$("fx-add-particle").onclick = () => {
|
||||
const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/);
|
||||
const at = lines.length;
|
||||
lines.push("particle", "{", "quota 100", "default_width 50 50", "default_height 50 50", "render_type add", "emitter point", "{", "emit_rate 20", "emit_speed 40 80", "emit_live_time 1000 1500", "}", "}");
|
||||
setText(lines.join(ending()), at);
|
||||
};
|
||||
$("fx-add-model").onclick = () => {
|
||||
const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/);
|
||||
const at = lines.length;
|
||||
lines.push("model", "{", "mesh_file none", "quota 20", "cull_each false", "follow_parent none", "end_condition never_end", "limit_time 1000", "start_create_particles 1", "link_biped_name none", "random_sync_index -1", "pos_offset 0 0 0", "rot_offset 0 0 0 1", "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 1", "emit_speed 0 0", "emit_live_time 1000 1000", "}", "}");
|
||||
setText(lines.join(ending()), at);
|
||||
};
|
||||
$("fx-add-emitter").onclick = () => {
|
||||
const selected = nodeAt(FXE.parsed, FXE.selected);
|
||||
const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent;
|
||||
if (!system || !["particle", "model"].includes(system.kind) || system.children.some((x) => x.kind === "emitter")) return;
|
||||
mutateLines((lines) => lines.splice(system.end, 0, "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 10", "emit_speed 0 0", "emit_live_time 1000 1000", "}"), system.start);
|
||||
};
|
||||
$("fx-add-affector").onclick = async () => {
|
||||
const selected = nodeAt(FXE.parsed, FXE.selected);
|
||||
const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent;
|
||||
if (!system || !["particle", "model"].includes(system.kind)) return;
|
||||
const type = await prompt("Add affector", "Type (Linear, Scale, TexRotation, Rotation, Emit)", "Linear");
|
||||
if (!type || !/^[a-z][\w-]*$/i.test(type)) return;
|
||||
mutateLines((lines) => lines.splice(system.end, 0, `affector ${type}`, "{", "wait_time 0", "}"), system.start);
|
||||
};
|
||||
$("fx-duplicate-block").onclick = () => {
|
||||
const node = nodeAt(FXE.parsed, FXE.selected);
|
||||
if (!node || !["particle", "model"].includes(node.kind)) return;
|
||||
const lines = FXE.parsed.lines.slice(), block = lines.slice(node.start, node.end + 1);
|
||||
lines.splice(node.end + 1, 0, "", ...block);
|
||||
setText(lines.join(ending()), node.end + 2);
|
||||
};
|
||||
function moveBlock(direction) {
|
||||
const node = nodeAt(FXE.parsed, FXE.selected);
|
||||
if (!node || !["particle", "model"].includes(node.kind)) return;
|
||||
const siblings = FXE.parsed.roots.filter((x) => x.kind === node.kind);
|
||||
const other = siblings[siblings.indexOf(node) + direction];
|
||||
if (!other) return;
|
||||
const [a, b] = node.start < other.start ? [node, other] : [other, node];
|
||||
const lines = FXE.parsed.lines.slice();
|
||||
const first = lines.slice(a.start, a.end + 1), gap = lines.slice(a.end + 1, b.start), second = lines.slice(b.start, b.end + 1);
|
||||
lines.splice(a.start, b.end - a.start + 1, ...second, ...gap, ...first);
|
||||
const selected = node === a ? a.start + second.length + gap.length : a.start;
|
||||
setText(lines.join(ending()), selected);
|
||||
}
|
||||
$("fx-move-up").onclick = () => moveBlock(-1);
|
||||
$("fx-move-down").onclick = () => moveBlock(1);
|
||||
$("fx-delete-block").onclick = async () => {
|
||||
const node = nodeAt(FXE.parsed, FXE.selected);
|
||||
if (!node || node.kind === "info") return;
|
||||
if (!(await confirmBox("Remove block", `Remove ${node.kind} ${node.type}?`, "Remove", "danger"))) return;
|
||||
mutateLines((lines) => lines.splice(node.start, node.end - node.start + 1), node.parent?.start ?? null);
|
||||
};
|
||||
$("fx-add-field").onclick = async () => {
|
||||
const node = nodeAt(FXE.parsed, FXE.selected);
|
||||
if (!node) return;
|
||||
const key = await prompt("Add field", "Script key (for example: texture)", "");
|
||||
if (!key || !/^[a-z][\w-]*$/i.test(key)) return;
|
||||
mutateLines((lines) => lines.splice(node.end, 0, `${key} `), node.start);
|
||||
};
|
||||
$("fx-play").onclick = () => {
|
||||
FXE.playing = !FXE.playing;
|
||||
$("fx-play").classList.toggle("on", FXE.playing);
|
||||
if (!FXE.playing) view?.setParticles([]);
|
||||
};
|
||||
$("fx-restart").onclick = refreshPreview;
|
||||
$("fx-grid").onclick = () => { $("fx-grid").classList.toggle("on"); drawGrid(); };
|
||||
$("fx-reset").onclick = () => view?.reset();
|
||||
document.querySelector('[data-view="fx"]').addEventListener("click", () => {
|
||||
ensureView();
|
||||
view?.resize();
|
||||
if (!FXE.src) { const src = profileSource(); if (src) { FXE.src = src; $("fx-src-label").textContent = "Profile packs"; loadList(); } }
|
||||
});
|
||||
async function save(as = false) {
|
||||
if (!FXE.text) return;
|
||||
if ([...FXE.text].some((c) => c.charCodeAt(0) > 255)) return toast("This script contains characters outside its original 8-bit encoding. Use ASCII for new fields.", true);
|
||||
let where = FXE.where;
|
||||
if (as || !where) {
|
||||
const file = await call("dialog:saveAs", FXE.name || "new_effect.txt", "txt");
|
||||
if (!file) return;
|
||||
where = { kind: "file", file };
|
||||
}
|
||||
try {
|
||||
await call("mdl:writeBack", where, rawToBytes(FXE.text));
|
||||
FXE.where = where; FXE.saved = FXE.text;
|
||||
if (where.kind === "file") FXE.name = where.file.replace(/^.*[\\/]/, "");
|
||||
$("fx-title").textContent = FXE.name;
|
||||
$("fx-dirty").hidden = true; $("fx-save").disabled = true;
|
||||
toast(`Saved ${FXE.name}`);
|
||||
if (FXE.src) { await call("mdl:reload", FXE.src); await loadList(); }
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
$("fx-save").onclick = () => save();
|
||||
$("fx-save-as").onclick = () => save(true);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!$("view-fx").classList.contains("on") || !e.ctrlKey || e.key.toLowerCase() !== "s") return;
|
||||
e.preventDefault(); save();
|
||||
});
|
||||
FXE.previewAsset = async (src, name) => {
|
||||
const changed = FXE.src?.kind !== src.kind || FXE.src?.folder !== src.folder || FXE.src?.sessionId !== src.sessionId;
|
||||
FXE.src = src;
|
||||
$("fx-src-label").textContent = "Hero source";
|
||||
document.querySelector('[data-view="fx"]').click();
|
||||
if (changed) await loadList();
|
||||
await openHit(() => call("mdl:read", src, name, "effect", true), name);
|
||||
};
|
||||
})();
|
||||
Vendored
+454
@@ -0,0 +1,454 @@
|
||||
"use strict";
|
||||
|
||||
// Effect scripts (resource/effect/*.txt) and their particle systems, following io3DEngine
|
||||
// (ioEffectFactory::ParseScript, ioParticleSystem::Update, ioParticleEmitter, the emit point
|
||||
// generators and affectors). Used by the map editor to play the map's effects.
|
||||
//
|
||||
// Units: times in ms, speeds / forces per second, angles in degrees (TexRotation speed: rad/s).
|
||||
// Everything is simulated in game space (map effects have no rotation or scale).
|
||||
|
||||
const Effects = (() => {
|
||||
// ---------- parser ----------
|
||||
/** → { info, particles, models } with editable attributes and nested emitters/affectors. */
|
||||
function parse(text) {
|
||||
const lines = text.split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, "").replace(/^\s+/, ""));
|
||||
let i = 0;
|
||||
const out = { info: {}, particles: [], models: [], light: false };
|
||||
const skipToBrace = () => {
|
||||
while (i < lines.length && !lines[i].startsWith("{")) i++;
|
||||
i++;
|
||||
};
|
||||
const readBlock = (onNested) => {
|
||||
const attrs = {};
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
if (!l || l.startsWith("//")) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (l.startsWith("}")) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
const sp = l.search(/\s/);
|
||||
const key = (sp < 0 ? l : l.slice(0, sp)).toLowerCase();
|
||||
const rest = sp < 0 ? "" : l.slice(sp + 1).trim();
|
||||
if (onNested && (key === "emitter" || key === "affector")) {
|
||||
i++;
|
||||
skipToBrace();
|
||||
onNested(key, rest, readBlock(null));
|
||||
continue;
|
||||
}
|
||||
attrs[key] = rest;
|
||||
i++;
|
||||
}
|
||||
return attrs;
|
||||
};
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
if (!l || l.startsWith("//") || l.startsWith("{") || l.startsWith("}")) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const tag = l.split(/\s/)[0].toLowerCase();
|
||||
i++;
|
||||
skipToBrace();
|
||||
if (tag === "particle" || tag === "model") {
|
||||
const ps = { emitter: null, affectors: [] };
|
||||
ps.attrs = readBlock((kind, type, attrs) => {
|
||||
if (kind === "emitter") ps.emitter = { type: type.toLowerCase(), attrs };
|
||||
else ps.affectors.push({ type: type.toLowerCase(), attrs });
|
||||
});
|
||||
out[tag === "model" ? "models" : "particles"].push(ps);
|
||||
} else {
|
||||
const attrs = readBlock(null);
|
||||
if (tag === "info") out.info = attrs;
|
||||
else if (tag === "light") out.light = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- small helpers ----------
|
||||
const nums = (s) => (s || "").trim().split(/\s+/).filter(Boolean).map(Number);
|
||||
const pair = (s, d) => {
|
||||
const n = nums(s);
|
||||
return n.length >= 2 ? n : n.length === 1 ? [n[0], n[0]] : d;
|
||||
};
|
||||
const vec = (s, d) => {
|
||||
const n = nums(s);
|
||||
return n.length >= 3 ? n.slice(0, 3) : d;
|
||||
};
|
||||
const rnd = (a, b) => a + Math.random() * (b - a);
|
||||
const norm = (v) => {
|
||||
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
||||
return [v[0] / l, v[1] / l, v[2] / l];
|
||||
};
|
||||
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const quatMul = (a, b) => [
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
];
|
||||
const quatRotate = (q, v) => {
|
||||
const u = q.slice(0, 3), w = q[3];
|
||||
const t = cross(u, v).map((n) => n * 2);
|
||||
const c = cross(u, t);
|
||||
return [v[0] + w * t[0] + c[0], v[1] + w * t[1] + c[1], v[2] + w * t[2] + c[2]];
|
||||
};
|
||||
/** Keyed curve "t v1 v2 …" (k values per key) → sampler(t01) → [v…] (linear, clamped) */
|
||||
function curve(s, k, dflt) {
|
||||
const n = nums(s);
|
||||
const keys = [];
|
||||
for (let j = 0; j + k < n.length + 1; j += k + 1) {
|
||||
const vals = n.slice(j + 1, j + 1 + k);
|
||||
while (vals.length < k) vals.push(0);
|
||||
keys.push([n[j], vals]);
|
||||
}
|
||||
if (!keys.length) keys.push([0, dflt]);
|
||||
return (t) => {
|
||||
let i = 0;
|
||||
while (i < keys.length && keys[i][0] <= t) i++;
|
||||
if (i === 0) return keys[0][1];
|
||||
if (i === keys.length) return keys[keys.length - 1][1];
|
||||
const [t0, a] = keys[i - 1], [t1, b] = keys[i];
|
||||
const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0;
|
||||
return a.map((v, j) => v + (b[j] - v) * f);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- one particle system ----------
|
||||
function makeSystem(ps, model = false) {
|
||||
const a = ps.attrs;
|
||||
const e = ps.emitter?.attrs || {};
|
||||
const type = ps.emitter?.type || "point";
|
||||
const sys = {
|
||||
model,
|
||||
mesh: model ? (a.mesh_file || "").trim() : "",
|
||||
quota: Number(a.quota) || 0,
|
||||
width: pair(a.default_width, [100, 100]),
|
||||
height: pair(a.default_height, [100, 100]),
|
||||
render: (a.render_type || "add").toLowerCase(),
|
||||
depth: a.depth_check !== "false",
|
||||
texture: (a.texture || "").split(/[\\/]/).pop(),
|
||||
linkBone: (a.link_biped_name || "").trim(),
|
||||
linkOffset: vec(a.pos_offset, [0, 0, 0]),
|
||||
followParent: (a.follow_parent || "none").toLowerCase(),
|
||||
linkRotation: nums(a.rot_offset).length === 4 ? nums(a.rot_offset) : [0, 0, 0, 1],
|
||||
billboard: (a.billboard_type || "point").toLowerCase(),
|
||||
endLimit: (a.end_condition || "").toLowerCase() === "time_limit",
|
||||
limit: Number(a.limit_time) || 0,
|
||||
commonDir: norm(vec(a.common_dir, [0, 1, 0])),
|
||||
commonFixed: a.common_dir_none_billboard === "true",
|
||||
interval: Number(a.iteration_interval) || 0,
|
||||
burst: Number(a.start_create_particles) || 0,
|
||||
// In the standalone editor the owner/bone offset is not applied to an
|
||||
// unattached model. The emitter position is its visible local origin.
|
||||
offset: model ? [0, 0, 0] : vec(a.pos_offset, [0, 0, 0]),
|
||||
rotOffset: model ? nums(a.rot_offset) : [],
|
||||
mod2x: a.modulate2x === "true",
|
||||
// emitter
|
||||
shape: type,
|
||||
emitPos: vec(e.emit_pos, [0, 0, 0]),
|
||||
emitDir: vec(e.emit_dir, [0, 1, 0]),
|
||||
angle: ((Number(e.emit_angle) || 0) * Math.PI) / 180,
|
||||
rate: e.emit_rate !== undefined ? Number(e.emit_rate) : 10,
|
||||
speed: pair(e.emit_speed, [1, 1]),
|
||||
life: pair(e.emit_live_time, [5000, 5000]),
|
||||
wait: Number(e.emit_wait_time) || 0,
|
||||
texRot: pair(e.emit_start_tex_rotation, [0, 0]).map((d) => (d * Math.PI) / 180),
|
||||
startRotAxis: norm(vec(e.start_rot_axis, [0, 1, 0])),
|
||||
startRotAngle: pair(e.start_rot_angle, [0, 0]),
|
||||
area: vec(e.area_size, [100, 100, 100]),
|
||||
inner: nums(e.inner_size),
|
||||
color: curve(e.color_rate, 3, [255, 255, 255]),
|
||||
alpha: curve(e.alpha_rate, 1, [1]),
|
||||
affectors: ps.affectors.map((f) => ({ type: f.type, a: f.attrs, wait: Number(f.attrs.wait_time) || 0 })),
|
||||
};
|
||||
for (const f of sys.affectors) {
|
||||
if (f.type === "linear") {
|
||||
f.force = vec(f.a.force_vector, [0, -100, 0]);
|
||||
f.rate = curve(f.a.force_rate, 1, [1]);
|
||||
} else if (f.type === "scale") f.scale = curve(f.a.scale_rate, model ? 3 : 2, model ? [1, 1, 1] : [1, 1]);
|
||||
else if (f.type === "texrotation") {
|
||||
f.range = pair(f.a.rot_range, [0, 0]).map((d) => (d * Math.PI) / 180);
|
||||
f.speed = pair(f.a.rot_speed, [0, 0]); // rad/s as the client uses it
|
||||
} else if (f.type === "emit") {
|
||||
f.power = Number(f.a.power) || 0;
|
||||
f.rate = curve(f.a.force_rate, 1, [1]);
|
||||
}
|
||||
}
|
||||
// emitter local axes (GenAreaAxis)
|
||||
const d = norm(sys.emitDir);
|
||||
let up = Math.abs(dot(d, [1, 0, 0])) > 0.99 ? [0, -1, 0] : [1, 0, 0];
|
||||
const side = cross(up, d);
|
||||
up = cross(d, side);
|
||||
sys.axes = { d, up, side };
|
||||
sys.parts = [];
|
||||
sys.time = 0;
|
||||
sys.acc = 0;
|
||||
sys.started = false;
|
||||
sys.stopped = false; // time_limit reached (no more emission)
|
||||
sys.ended = false;
|
||||
return sys;
|
||||
}
|
||||
|
||||
function spawnPoint(s) {
|
||||
const { d, up, side } = s.axes;
|
||||
const [sx, sy, sz] = s.area;
|
||||
const RX = up.map((v) => (v * sx) / 2), RY = d.map((v) => (v * sy) / 2), RZ = side.map((v) => (v * sz) / 2);
|
||||
const c = s.emitPos;
|
||||
const at = (a, b, cc) => [c[0] + a * RX[0] + b * RY[0] + cc * RZ[0], c[1] + a * RX[1] + b * RY[1] + cc * RZ[1], c[2] + a * RX[2] + b * RY[2] + cc * RZ[2]];
|
||||
const r = () => Math.random() * 2 - 1;
|
||||
switch (s.shape) {
|
||||
case "box":
|
||||
return at(r(), r(), r());
|
||||
case "cylinder": {
|
||||
let x = r(), z = r();
|
||||
const l = Math.hypot(x, z);
|
||||
if (l > 1) (x /= l), (z /= l);
|
||||
return at(x, r(), z);
|
||||
}
|
||||
case "ellipse": {
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const ix = s.inner[0] ?? 0.5, iz = s.inner[1] ?? 0.5;
|
||||
return at(ix * Math.sin(ang), Math.random(), iz * Math.cos(ang));
|
||||
}
|
||||
case "ellipsoid": {
|
||||
let x = r(), y = r(), z = r();
|
||||
const l = Math.hypot(x, y, z);
|
||||
if (l > 1) (x /= l), (y /= l), (z /= l);
|
||||
return at(x, y, z);
|
||||
}
|
||||
case "hollowellipsoid": {
|
||||
const al = Math.random() * Math.PI * 2, be = Math.random() * Math.PI;
|
||||
const inner = s.inner.map((v) => Math.max(0, Math.min(1, v)));
|
||||
const rx = rnd(inner[0] ?? 0.5, 1), ry = rnd(inner[1] ?? 0.5, 1), rz = rnd(inner[2] ?? 0.5, 1);
|
||||
return at(rx * Math.cos(al) * Math.sin(be), ry * Math.cos(be), rz * Math.sin(al) * Math.sin(be));
|
||||
}
|
||||
default:
|
||||
return c.slice();
|
||||
}
|
||||
}
|
||||
/** emit_dir turned by a random angle within the cone, around a random azimuth. */
|
||||
function coneDir(s) {
|
||||
const { d, up, side } = s.axes;
|
||||
if (!s.angle) return d.slice();
|
||||
const th = Math.random() * s.angle, ph = Math.random() * Math.PI * 2;
|
||||
const u = norm(up), v = norm(side);
|
||||
const k = Math.sin(th);
|
||||
return norm([d[0] * Math.cos(th) + (u[0] * Math.cos(ph) + v[0] * Math.sin(ph)) * k, d[1] * Math.cos(th) + (u[1] * Math.cos(ph) + v[1] * Math.sin(ph)) * k, d[2] * Math.cos(th) + (u[2] * Math.cos(ph) + v[2] * Math.sin(ph)) * k]);
|
||||
}
|
||||
|
||||
function spawn(s, origin, lead) {
|
||||
const rotation = s.ownerRotation && s.followParent !== "pos_only" ? s.ownerRotation : null;
|
||||
const p = rotation ? quatRotate(rotation, spawnPoint(s)) : spawnPoint(s);
|
||||
const dir = rotation ? quatRotate(rotation, coneDir(s)) : coneDir(s);
|
||||
const speed = rnd(s.speed[0], s.speed[1]);
|
||||
const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : s.offset;
|
||||
const part = {
|
||||
pos: [p[0] + origin[0] + offset[0] + dir[0] * speed * lead, p[1] + origin[1] + offset[1] + dir[1] * speed * lead, p[2] + origin[2] + offset[2] + dir[2] * speed * lead],
|
||||
dir,
|
||||
speed,
|
||||
age: 0,
|
||||
life: Math.max(1, rnd(s.life[0], s.life[1])),
|
||||
w0: rnd(s.width[0], s.width[1]),
|
||||
h0: rnd(s.height[0], s.height[1]),
|
||||
rot: rnd(s.texRot[0], s.texRot[1]),
|
||||
rotSpeed: 0,
|
||||
};
|
||||
if (s.model) {
|
||||
// Effect scripts use Direct3D's left-handed axis-angle convention.
|
||||
const angle = -rnd(s.startRotAngle[0], s.startRotAngle[1]) * Math.PI / 360;
|
||||
const axis = s.startRotAxis;
|
||||
const q = [axis[0] * Math.sin(angle), axis[1] * Math.sin(angle), axis[2] * Math.sin(angle), Math.cos(angle)];
|
||||
part.modelRotation = quatMul(rotation || [0, 0, 0, 1], quatMul(s.rotOffset.length === 4 ? s.rotOffset : [0, 0, 0, 1], q));
|
||||
part.modelScale = s.affectors.find((f) => f.type === "scale")?.scale(0) || [1, 1, 1];
|
||||
}
|
||||
for (const f of s.affectors) {
|
||||
if (f.type === "texrotation") {
|
||||
part.rot = rnd(f.range[0], f.range[1]);
|
||||
part.rotSpeed = rnd(f.speed[0], f.speed[1]);
|
||||
}
|
||||
}
|
||||
part.w = part.w0;
|
||||
part.h = part.h0;
|
||||
s.parts.push(part);
|
||||
}
|
||||
|
||||
function step(s, dt, origin) {
|
||||
const dts = dt / 1000;
|
||||
if (!s.started) {
|
||||
s.started = true;
|
||||
for (let k = 0; k < Math.min(s.burst, s.quota || s.burst); k++) spawn(s, origin, 0);
|
||||
}
|
||||
s.time += dt;
|
||||
// expire + age
|
||||
s.parts = s.parts.filter((p) => p.age < p.life);
|
||||
for (const p of s.parts) p.age += dt;
|
||||
// affectors
|
||||
for (const f of s.affectors) {
|
||||
if (s.time < f.wait) continue;
|
||||
for (const p of s.parts) {
|
||||
const t = Math.min(1, p.age / p.life);
|
||||
if (f.type === "linear") {
|
||||
const r = f.rate(t)[0];
|
||||
const v = [p.dir[0] * p.speed + f.force[0] * dts * r, p.dir[1] * p.speed + f.force[1] * dts * r, p.dir[2] * p.speed + f.force[2] * dts * r];
|
||||
p.speed = Math.hypot(v[0], v[1], v[2]);
|
||||
if (p.speed > 1e-6) p.dir = [v[0] / p.speed, v[1] / p.speed, v[2] / p.speed];
|
||||
} else if (f.type === "scale") {
|
||||
if (s.model) p.modelScale = f.scale(t);
|
||||
else {
|
||||
const [sx, sy] = f.scale(t);
|
||||
p.w = p.w0 * sx;
|
||||
p.h = p.h0 * sy;
|
||||
}
|
||||
} else if (f.type === "texrotation") p.rot += p.rotSpeed * dts;
|
||||
else if (f.type === "emit") {
|
||||
const emitPos = s.ownerRotation && s.followParent !== "pos_only" ? quatRotate(s.ownerRotation, s.emitPos) : s.emitPos;
|
||||
const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : [0, 0, 0];
|
||||
const c = [origin[0] + offset[0] + emitPos[0], origin[1] + offset[1] + emitPos[1], origin[2] + offset[2] + emitPos[2]];
|
||||
const d = norm([p.pos[0] - c[0], p.pos[1] - c[1], p.pos[2] - c[2]]);
|
||||
const k = f.power * dts * f.rate(t)[0];
|
||||
p.pos[0] += d[0] * k;
|
||||
p.pos[1] += d[1] * k;
|
||||
p.pos[2] += d[2] * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
// integrate
|
||||
for (const p of s.parts) {
|
||||
p.pos[0] += p.dir[0] * p.speed * dts;
|
||||
p.pos[1] += p.dir[1] * p.speed * dts;
|
||||
p.pos[2] += p.dir[2] * p.speed * dts;
|
||||
}
|
||||
// emit
|
||||
if (s.endLimit && s.time >= s.limit) s.stopped = true;
|
||||
if (!s.stopped && s.time >= s.wait && s.quota > 0) {
|
||||
s.acc += (s.rate * dt) / 1000;
|
||||
let n = Math.floor(s.acc);
|
||||
s.acc -= n;
|
||||
n = Math.min(n, s.quota - s.parts.length);
|
||||
for (let k = 0; k < n; k++) spawn(s, origin, (k * dts) / Math.max(1, n));
|
||||
}
|
||||
if ((s.stopped || (!s.endLimit && s.rate < 0.1)) && !s.parts.length && s.time > 0) s.ended = true;
|
||||
}
|
||||
|
||||
// ---------- an effect instance ----------
|
||||
function instance(def, origin) {
|
||||
return { def, origin, systems: def.particles.map((p) => makeSystem(p)), modelSystems: def.models.map((m) => makeSystem(m, true)), restartAt: 0 };
|
||||
}
|
||||
function update(inst, dt) {
|
||||
let live = false;
|
||||
for (const s of [...inst.systems, ...inst.modelSystems]) {
|
||||
if (s.ended) continue;
|
||||
if (s.heroAttached && s.followParent !== "none") {
|
||||
const base = s.ownerOrigin || inst.origin, offset = s.ownerOffset || [0, 0, 0];
|
||||
const current = [base[0] + offset[0], base[1] + offset[1], base[2] + offset[2]];
|
||||
const rotation = s.followParent === "all" ? (s.ownerRotation || [0, 0, 0, 1]) : [0, 0, 0, 1];
|
||||
if (s.previousParent) {
|
||||
const inverse = [-s.previousRotation[0], -s.previousRotation[1], -s.previousRotation[2], s.previousRotation[3]];
|
||||
for (const p of s.parts) {
|
||||
const local = quatRotate(inverse, [p.pos[0] - s.previousParent[0], p.pos[1] - s.previousParent[1], p.pos[2] - s.previousParent[2]]);
|
||||
const next = quatRotate(rotation, local);
|
||||
p.pos = [current[0] + next[0], current[1] + next[1], current[2] + next[2]];
|
||||
p.dir = quatRotate(rotation, quatRotate(inverse, p.dir));
|
||||
if (p.modelRotation) p.modelRotation = quatMul(rotation, quatMul(inverse, p.modelRotation));
|
||||
}
|
||||
}
|
||||
s.previousParent = current;
|
||||
s.previousRotation = rotation;
|
||||
}
|
||||
if (s.interval > 0) {
|
||||
s.left = (s.left || 0) + dt;
|
||||
while (s.left >= s.interval) {
|
||||
step(s, s.interval, s.ownerOrigin || inst.origin);
|
||||
s.left -= s.interval;
|
||||
}
|
||||
} else step(s, Math.min(dt, 100), s.ownerOrigin || inst.origin);
|
||||
if (!s.ended) live = true;
|
||||
}
|
||||
// map effects play while their systems live; one-shot ones restart in the preview after a pause
|
||||
if (!live && !inst.heroPreview) {
|
||||
inst.restartAt += dt;
|
||||
if (inst.restartAt > 1200) {
|
||||
inst.systems = inst.def.particles.map(makeSystem);
|
||||
inst.modelSystems = inst.def.models.map((m) => makeSystem(m, true));
|
||||
inst.restartAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BLEND = {
|
||||
add: { op: "add", src: "src_alpha", dst: "one" },
|
||||
src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
||||
screen: { op: "add", src: "inv_dest_color", dst: "one" },
|
||||
lighten: { op: "max", src: "one", dst: "one" },
|
||||
darken: { op: "min", src: "one", dst: "one" },
|
||||
rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" },
|
||||
subtract: { op: "subtract", src: "src_alpha", dst: "one" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Quads of every live particle (6 vertices × [x y z u v r g b a]) grouped per system.
|
||||
* cam: { right, up, dir } in game space.
|
||||
*/
|
||||
function build(inst, cam) {
|
||||
const out = [];
|
||||
for (const s of inst.systems) {
|
||||
if (!s.parts.length) continue;
|
||||
const data = new Float32Array(s.parts.length * 6 * 9);
|
||||
let o = 0;
|
||||
for (const p of s.parts) {
|
||||
const t = Math.min(1, p.age / p.life);
|
||||
const c = s.color(t);
|
||||
const al = Math.max(0, Math.min(1, s.alpha(t)[0]));
|
||||
let X, Y;
|
||||
if (s.billboard === "common") {
|
||||
Y = s.commonDir;
|
||||
X = s.commonFixed ? norm(cross(Math.abs(Y[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0], Y)) : norm(cross(Y, cam.dir));
|
||||
} else if (s.billboard === "direction") {
|
||||
X = p.dir;
|
||||
Y = cross(X, cam.dir);
|
||||
} else {
|
||||
X = cam.right;
|
||||
Y = cam.up;
|
||||
}
|
||||
const hw = p.w / 2, hh = p.h / 2;
|
||||
const cs = Math.cos(p.rot), sn = Math.sin(p.rot);
|
||||
const uv = (u, v) => {
|
||||
// rotation about (0.5, 0.5)
|
||||
const x = u - 0.5, y = v - 0.5;
|
||||
return [x * cs - y * sn + 0.5, x * sn + y * cs + 0.5];
|
||||
};
|
||||
const corner = (sx, sy) => [p.pos[0] + X[0] * hw * sx + Y[0] * hh * sy, p.pos[1] + X[1] * hw * sx + Y[1] * hh * sy, p.pos[2] + X[2] * hw * sx + Y[2] * hh * sy];
|
||||
const q = [
|
||||
[corner(-1, 1), uv(0, 0)],
|
||||
[corner(1, 1), uv(1, 0)],
|
||||
[corner(1, -1), uv(1, 1)],
|
||||
[corner(-1, -1), uv(0, 1)],
|
||||
];
|
||||
for (const k of [0, 1, 2, 0, 2, 3]) {
|
||||
const [pp, tt] = q[k];
|
||||
data[o++] = pp[0];
|
||||
data[o++] = pp[1];
|
||||
data[o++] = pp[2];
|
||||
data[o++] = tt[0];
|
||||
data[o++] = tt[1];
|
||||
data[o++] = c[0] / 255;
|
||||
data[o++] = c[1] / 255;
|
||||
data[o++] = c[2] / 255;
|
||||
data[o++] = al;
|
||||
}
|
||||
}
|
||||
out.push({ texture: s.texture, blend: BLEND[s.render] || BLEND.add, depth: s.depth, mod2x: s.mod2x, data, count: s.parts.length * 6 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const count = (inst) => inst.systems.reduce((a, s) => a + s.parts.length, 0);
|
||||
return { parse, instance, update, build, count };
|
||||
})();
|
||||
window.Effects = Effects;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,666 @@
|
||||
"use strict";
|
||||
|
||||
// Small WebGL2 viewer for the model and map tools: textured meshes (positions can be updated
|
||||
// every frame for CPU skinning), per-mesh position/scale, flat-coloured meshes (collision),
|
||||
// wireframe, line / point overlays (grid, bones, boxes, markers), map lighting and fog, an orbit
|
||||
// camera and picking rays.
|
||||
// The game data is Direct3D left-handed (Y up); the scene is drawn through a Z mirror so it looks
|
||||
// the same as in the client. Culling is off (many game materials are two-sided).
|
||||
|
||||
const GL3D = (() => {
|
||||
// ---------- column-major mat4 (GL convention) ----------
|
||||
const M = {
|
||||
mul(a, b) {
|
||||
const o = new Float32Array(16);
|
||||
for (let c = 0; c < 4; c++)
|
||||
for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
||||
return o;
|
||||
},
|
||||
persp(fovy, aspect, near, far) {
|
||||
const f = 1 / Math.tan(fovy / 2);
|
||||
const o = new Float32Array(16);
|
||||
o[0] = f / aspect;
|
||||
o[5] = f;
|
||||
o[10] = (far + near) / (near - far);
|
||||
o[11] = -1;
|
||||
o[14] = (2 * far * near) / (near - far);
|
||||
return o;
|
||||
},
|
||||
lookAt(eye, at, up) {
|
||||
const z = norm(sub(eye, at));
|
||||
const x = norm(cross(up, z));
|
||||
const y = cross(z, x);
|
||||
return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]);
|
||||
},
|
||||
};
|
||||
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
const norm = (a) => {
|
||||
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
||||
return [a[0] / l, a[1] / l, a[2] / l];
|
||||
};
|
||||
const MIRROR = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1]); // D3D LH → GL RH
|
||||
const IDENT3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
|
||||
/** Mirror · translate · rotate · scale, for a mesh placed at pos (game coordinates). */
|
||||
const placed = (pos, scale, rotation = [0, 0, 0]) => {
|
||||
const [x, y, z] = rotation.map((v) => (v * Math.PI) / 180);
|
||||
const cx = Math.cos(x), sx = Math.sin(x), cy = Math.cos(y), sy = Math.sin(y), cz = Math.cos(z), sz = Math.sin(z);
|
||||
const rx = new Float32Array([1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1]);
|
||||
const ry = new Float32Array([cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1]);
|
||||
const rz = new Float32Array([cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
||||
const r = M.mul(ry, M.mul(rx, rz));
|
||||
const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]);
|
||||
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]);
|
||||
return M.mul(t, M.mul(MIRROR, M.mul(r, s)));
|
||||
};
|
||||
const placedQuaternion = (pos, scale, rotation) => {
|
||||
const length = Math.hypot(...rotation) || 1;
|
||||
const [x, y, z, w] = rotation.map((v) => v / length);
|
||||
const r = new Float32Array([
|
||||
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
|
||||
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
|
||||
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
|
||||
0, 0, 0, 1,
|
||||
]);
|
||||
const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]);
|
||||
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]);
|
||||
return M.mul(t, M.mul(MIRROR, M.mul(r, s)));
|
||||
};
|
||||
|
||||
const MESH_VS = `#version 300 es
|
||||
layout(location=0) in vec3 aPos; layout(location=1) in vec3 aNrm;
|
||||
layout(location=2) in vec2 aUv0; layout(location=3) in vec2 aUv1;
|
||||
uniform mat4 uVP; uniform mat4 uModel;
|
||||
out vec2 vUv0; out vec2 vUv1; out vec3 vN; out vec3 vW;
|
||||
void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vN = mat3(uModel) * aNrm; vUv0 = aUv0; vUv1 = aUv1; gl_Position = uVP * w; }`;
|
||||
const MESH_FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vUv0; in vec2 vUv1; in vec3 vN; in vec3 vW;
|
||||
uniform sampler2D uTex; uniform sampler2D uLm; uniform int uHasTex; uniform int uHasLm; uniform int uUvSet; uniform int uLmSet;
|
||||
uniform int uLit; uniform vec3 uEye; uniform vec4 uTint; uniform float uAlphaCut; uniform int uHasNrm; uniform int uOpaque;
|
||||
uniform int uFlat; uniform vec4 uColor; uniform mat3 uTexM; uniform mat3 uLmM; uniform float uColorMul; uniform float uEmit;
|
||||
uniform int uLmAddDiffuse; uniform vec3 uDiffuse;
|
||||
uniform int uEnv; uniform vec3 uAmbient; uniform vec3 uLightCol; uniform vec3 uLightDir;
|
||||
uniform int uFog; uniform vec3 uFogCol; uniform vec2 uFogRange;
|
||||
out vec4 o;
|
||||
vec3 fogged(vec3 c){
|
||||
if (uFog==0) return c;
|
||||
float f = clamp((distance(vW, uEye) - uFogRange.x) / max(1.0, uFogRange.y - uFogRange.x), 0.0, 1.0);
|
||||
return mix(c, uFogCol, f);
|
||||
}
|
||||
void main(){
|
||||
vec3 n = uHasNrm==1 ? normalize(vN) : normalize(cross(dFdx(vW), dFdy(vW)));
|
||||
if (uFlat==1) {
|
||||
float d = abs(dot(n, normalize(vec3(0.3, 1.0, 0.2))));
|
||||
o = vec4(uColor.rgb * (0.55 + 0.45 * d), uColor.a);
|
||||
return;
|
||||
}
|
||||
vec2 tuv = (vec3(uUvSet==1 ? vUv1 : vUv0, 1.0) * uTexM).xy;
|
||||
vec4 c = uHasTex==1 ? texture(uTex, tuv) : vec4(0.72,0.70,0.76,1.0);
|
||||
if (c.a < uAlphaCut) discard;
|
||||
float a = uOpaque==1 ? 1.0 : c.a;
|
||||
vec3 emit = c.rgb * uEmit;
|
||||
c.rgb *= uColorMul;
|
||||
if (uHasLm==1) {
|
||||
vec3 lm = texture(uLm, (vec3(uLmSet==1 ? vUv1 : vUv0, 1.0) * uLmM).xy).rgb;
|
||||
c.rgb *= uLmAddDiffuse==1 ? min(vec3(1.0), lm + uDiffuse) : lm;
|
||||
o = vec4(fogged(c.rgb + emit), a) * uTint; return;
|
||||
}
|
||||
vec3 light;
|
||||
if (uEnv==1) {
|
||||
float d = max(dot(n, -uLightDir), 0.0);
|
||||
light = uAmbient + uLightCol * (0.35 + 0.65 * d);
|
||||
} else {
|
||||
vec3 l = normalize(uEye - vW + vec3(0.0, 60.0, 0.0));
|
||||
light = vec3(uLit==1 ? 0.45 + 0.65 * abs(dot(n, l)) : 1.0);
|
||||
}
|
||||
o = vec4(fogged(c.rgb * light + emit), a) * uTint;
|
||||
}`;
|
||||
const LINE_VS = `#version 300 es
|
||||
layout(location=0) in vec3 aPos; layout(location=1) in vec4 aCol;
|
||||
uniform mat4 uVP; uniform mat4 uModel; uniform float uPoint;
|
||||
out vec4 vCol;
|
||||
void main(){ vCol = aCol; gl_Position = uVP * uModel * vec4(aPos,1.0); gl_PointSize = uPoint; }`;
|
||||
const LINE_FS = `#version 300 es
|
||||
precision mediump float; in vec4 vCol; uniform int uRound; out vec4 o;
|
||||
void main(){
|
||||
if (uRound==1) { float r = length(gl_PointCoord-vec2(0.5)); if (r>0.5) discard; o = vec4(r > 0.36 ? vCol.rgb*0.35 : vCol.rgb, vCol.a); return; }
|
||||
o = vCol;
|
||||
}`;
|
||||
|
||||
const PART_VS = `#version 300 es
|
||||
layout(location=0) in vec3 aPos; layout(location=1) in vec2 aUv; layout(location=2) in vec4 aCol;
|
||||
uniform mat4 uVP; uniform mat4 uModel;
|
||||
out vec2 vUv; out vec4 vCol; out vec3 vW;
|
||||
void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vUv = aUv; vCol = aCol; gl_Position = uVP * w; }`;
|
||||
const PART_FS = `#version 300 es
|
||||
precision mediump float;
|
||||
in vec2 vUv; in vec4 vCol; in vec3 vW;
|
||||
uniform sampler2D uTex; uniform int uHasTex; uniform float uMod;
|
||||
out vec4 o;
|
||||
void main(){
|
||||
vec4 c = (uHasTex==1 ? texture(uTex, vUv) : vec4(1.0)) * vec4(vCol.rgb * uMod, vCol.a);
|
||||
if (c.a <= 1.0/255.0) discard;
|
||||
o = c;
|
||||
}`;
|
||||
|
||||
function program(gl, vs, fs) {
|
||||
const p = gl.createProgram();
|
||||
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
gl.attachShader(p, s);
|
||||
}
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
||||
const u = {};
|
||||
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const name = gl.getActiveUniform(p, i).name;
|
||||
u[name] = gl.getUniformLocation(p, name);
|
||||
}
|
||||
return { p, u };
|
||||
}
|
||||
|
||||
class View {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
const gl = (this.gl = canvas.getContext("webgl2", { antialias: true, preserveDrawingBuffer: true, alpha: options.alpha !== false, premultipliedAlpha: !!options.premultipliedAlpha }));
|
||||
if (!gl) throw new Error("WebGL2 isn't available");
|
||||
this.meshProg = program(gl, MESH_VS, MESH_FS);
|
||||
this.lineProg = program(gl, LINE_VS, LINE_FS);
|
||||
this.partProg = program(gl, PART_VS, PART_FS);
|
||||
this.particles = []; // [{ tex, blend, depth, mod2x, data, count }]
|
||||
this.partVao = gl.createVertexArray();
|
||||
this.partBuf = gl.createBuffer();
|
||||
gl.bindVertexArray(this.partVao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 36, 0);
|
||||
gl.enableVertexAttribArray(1);
|
||||
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 36, 12);
|
||||
gl.enableVertexAttribArray(2);
|
||||
gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 36, 20);
|
||||
gl.bindVertexArray(null);
|
||||
this.meshes = new Map(); // id → gpu mesh
|
||||
this.overlays = new Map(); // name → { vao, count, mode, depth, point }
|
||||
this.opts = { textures: true, wire: false, lit: true, solid: true };
|
||||
/** Map lighting: { ambient:[r,g,b], lightColor:[r,g,b], lightDir:[x,y,z] (game coords), fog:{color,start,end}|null, clear:[r,g,b]|null } */
|
||||
this.env = null;
|
||||
this.cam = { yaw: 0.6, pitch: 0.35, dist: 300, target: [0, 80, 0], fov: 0.8 };
|
||||
this.white = this.texture(new ImageData(new Uint8ClampedArray([255, 255, 255, 255]), 1, 1));
|
||||
this.onPointerDown = null; // (e) → true when the tool takes the drag (no orbit)
|
||||
this.beforeDraw = null;
|
||||
this.onCamera = null;
|
||||
this.dirty = true;
|
||||
this.controls();
|
||||
new ResizeObserver(() => this.resize()).observe(canvas.parentElement);
|
||||
const loop = () => {
|
||||
if (this.dirty && this.canvas.offsetParent) this.draw();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
resize() {
|
||||
const r = this.canvas.parentElement.getBoundingClientRect();
|
||||
const dpr = devicePixelRatio || 1;
|
||||
this.canvas.width = Math.max(1, Math.round(r.width * dpr));
|
||||
this.canvas.height = Math.max(1, Math.round(r.height * dpr));
|
||||
this.canvas.style.width = `${r.width}px`;
|
||||
this.canvas.style.height = `${r.height}px`;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
// ---------- resources ----------
|
||||
texture(img) {
|
||||
const gl = this.gl;
|
||||
const t = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
const pow2 = (n) => (n & (n - 1)) === 0;
|
||||
if (pow2(img.width) && pow2(img.height)) {
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||||
} else gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* mesh: { pos, normal?, uv0?, uv1?, indices, groups: [{ start, count, tex?, lmTex?, uvSet, lmSet, alphaCut, blend, color? }] }
|
||||
* opts: { pos, scale } places it (map objects); { wireColor } draws its wireframe always.
|
||||
*/
|
||||
addMesh(id, m, opts = {}) {
|
||||
const gl = this.gl;
|
||||
this.removeMesh(id);
|
||||
const vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
const bufs = [];
|
||||
const buf = (loc, data, size, usage = gl.STATIC_DRAW) => {
|
||||
const b = gl.createBuffer();
|
||||
bufs.push(b);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, b);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, data, usage);
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
|
||||
return b;
|
||||
};
|
||||
const V = m.pos.length / 3;
|
||||
const g = { vao, bufs, groups: m.groups, visible: true, hasNormal: !!m.normal, model: MIRROR, wireColor: opts.wireColor || null };
|
||||
if (opts.pos || opts.scale || opts.rotation) g.model = placed(opts.pos || [0, 0, 0], opts.scale || [1, 1, 1], opts.rotation || [0, 0, 0]);
|
||||
g.pos = buf(0, m.pos, 3, gl.DYNAMIC_DRAW);
|
||||
g.nrm = buf(1, m.normal || new Float32Array(V * 3), 3, gl.DYNAMIC_DRAW);
|
||||
buf(2, m.uv0 || new Float32Array(V * 2), 2);
|
||||
buf(3, m.uv1 || m.uv0 || new Float32Array(V * 2), 2);
|
||||
const idx = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices);
|
||||
g.ibo = gl.createBuffer();
|
||||
bufs.push(g.ibo);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.ibo);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
|
||||
gl.bindVertexArray(null);
|
||||
// wireframe: every triangle edge once
|
||||
const seen = new Set();
|
||||
const edges = [];
|
||||
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||
for (const [a, b] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) {
|
||||
const k = a < b ? a * 4194304 + b : b * 4194304 + a;
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
edges.push(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
g.wvao = gl.createVertexArray();
|
||||
gl.bindVertexArray(g.wvao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, g.pos);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.disableVertexAttribArray(1);
|
||||
g.wibo = gl.createBuffer();
|
||||
bufs.push(g.wibo);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.wibo);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(edges), gl.STATIC_DRAW);
|
||||
g.wcount = edges.length;
|
||||
gl.bindVertexArray(null);
|
||||
this.meshes.set(id, g);
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
setTransform(id, pos, scale = [1, 1, 1], rotation = [0, 0, 0]) {
|
||||
const g = this.meshes.get(id);
|
||||
if (g) g.model = placed(pos, scale, rotation);
|
||||
this.dirty = true;
|
||||
}
|
||||
setTransformQuaternion(id, pos, scale, rotation) {
|
||||
const g = this.meshes.get(id);
|
||||
if (g) g.model = placedQuaternion(pos, scale, rotation);
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
updateMesh(id, pos, normal) {
|
||||
const g = this.meshes.get(id);
|
||||
if (!g) return;
|
||||
const gl = this.gl;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, g.pos);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, pos);
|
||||
if (normal) {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, g.nrm);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, normal);
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
setGroupTexture(id, gi, tex, slot = "tex") {
|
||||
const g = this.meshes.get(id);
|
||||
if (g?.groups[gi]) g.groups[gi][slot] = tex;
|
||||
this.dirty = true;
|
||||
}
|
||||
setVisible(id, v) {
|
||||
const g = this.meshes.get(id);
|
||||
if (g) g.visible = v;
|
||||
this.dirty = true;
|
||||
}
|
||||
setTint(id, tint) {
|
||||
const g = this.meshes.get(id);
|
||||
if (g) g.tint = tint;
|
||||
this.dirty = true;
|
||||
}
|
||||
setHighlight(id, on) {
|
||||
const g = this.meshes.get(id);
|
||||
if (g) g.highlight = on;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
removeMesh(id) {
|
||||
const g = this.meshes.get(id);
|
||||
if (!g) return;
|
||||
const gl = this.gl;
|
||||
gl.deleteVertexArray(g.vao);
|
||||
gl.deleteVertexArray(g.wvao);
|
||||
for (const b of g.bufs) gl.deleteBuffer(b);
|
||||
this.meshes.delete(id);
|
||||
this.dirty = true;
|
||||
}
|
||||
clear() {
|
||||
for (const id of [...this.meshes.keys()]) this.removeMesh(id);
|
||||
for (const n of [...this.overlays.keys()]) this.setOverlay(n, null);
|
||||
}
|
||||
|
||||
/** Line / point overlay: data = Float32Array of x,y,z,r,g,b,a per vertex. */
|
||||
setOverlay(name, data, { mode = "lines", depth = true, point = 6 } = {}) {
|
||||
const gl = this.gl;
|
||||
const old = this.overlays.get(name);
|
||||
const visible = old ? old.visible : true;
|
||||
if (old) {
|
||||
gl.deleteVertexArray(old.vao);
|
||||
gl.deleteBuffer(old.buf);
|
||||
this.overlays.delete(name);
|
||||
}
|
||||
this.dirty = true;
|
||||
if (!data || !data.length) return;
|
||||
const vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 28, 0);
|
||||
gl.enableVertexAttribArray(1);
|
||||
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 28, 12);
|
||||
gl.bindVertexArray(null);
|
||||
this.overlays.set(name, { vao, buf, count: data.length / 7, mode, depth, point, visible });
|
||||
}
|
||||
showOverlay(name, v) {
|
||||
const o = this.overlays.get(name);
|
||||
if (o) o.visible = v;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
// ---------- camera ----------
|
||||
frame(min, max) {
|
||||
const c = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
||||
const r = Math.max(1, Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2);
|
||||
this.cam.target = [c[0], c[1], -c[2]];
|
||||
this.cam.dist = (r / Math.sin(this.cam.fov / 2)) * 1.05;
|
||||
this.cam.radius = r;
|
||||
this.home = { ...this.cam, target: this.cam.target.slice() };
|
||||
this.moved();
|
||||
}
|
||||
reset() {
|
||||
if (this.home) this.cam = { ...this.home, target: this.home.target.slice() };
|
||||
this.moved();
|
||||
}
|
||||
/** Straight down (for placing things on the floor plan). */
|
||||
topView() {
|
||||
this.cam.pitch = 1.5;
|
||||
this.cam.yaw = 0;
|
||||
this.moved();
|
||||
}
|
||||
moved() {
|
||||
this.dirty = true;
|
||||
this.onCamera?.();
|
||||
}
|
||||
eye() {
|
||||
const c = this.cam;
|
||||
return [c.target[0] + c.dist * Math.cos(c.pitch) * Math.sin(c.yaw), c.target[1] + c.dist * Math.sin(c.pitch), c.target[2] + c.dist * Math.cos(c.pitch) * Math.cos(c.yaw)];
|
||||
}
|
||||
viewProj() {
|
||||
const aspect = this.canvas.width / Math.max(1, this.canvas.height);
|
||||
const r = this.cam.radius || 100;
|
||||
const near = Math.max(0.05, this.cam.dist - r * 20) * 0.02 + 0.05;
|
||||
const far = this.cam.dist + r * 40 + 1000;
|
||||
return M.mul(M.persp(this.cam.fov, aspect, near, far), M.lookAt(this.eye(), this.cam.target, [0, 1, 0]));
|
||||
}
|
||||
|
||||
/** Camera right / up / forward in game coordinates (for billboards). */
|
||||
cameraAxes() {
|
||||
const eye = this.eye();
|
||||
const f = norm(sub(this.cam.target, eye));
|
||||
const r = norm(cross(f, [0, 1, 0]));
|
||||
const u = cross(r, f);
|
||||
const g = (v) => [v[0], v[1], -v[2]];
|
||||
return { right: g(r), up: g(u), dir: g(f) };
|
||||
}
|
||||
/** Particle quads to draw this frame (effects). */
|
||||
setParticles(list) {
|
||||
this.particles = list || [];
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/** Picking ray through a screen point (client px), in game coordinates. → { o, d } */
|
||||
ray(clientX, clientY) {
|
||||
const b = this.canvas.getBoundingClientRect();
|
||||
const nx = ((clientX - b.left) / b.width) * 2 - 1;
|
||||
const ny = 1 - ((clientY - b.top) / b.height) * 2;
|
||||
const eye = this.eye();
|
||||
const f = norm(sub(this.cam.target, eye));
|
||||
const r = norm(cross(f, [0, 1, 0]));
|
||||
const u = cross(r, f);
|
||||
const t = Math.tan(this.cam.fov / 2);
|
||||
const aspect = b.width / Math.max(1, b.height);
|
||||
const d = norm([f[0] + nx * t * aspect * r[0] + ny * t * u[0], f[1] + nx * t * aspect * r[1] + ny * t * u[1], f[2] + nx * t * aspect * r[2] + ny * t * u[2]]);
|
||||
return { o: [eye[0], eye[1], -eye[2]], d: [d[0], d[1], -d[2]] };
|
||||
}
|
||||
|
||||
controls() {
|
||||
const c = this.canvas;
|
||||
let drag = null;
|
||||
c.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
if (this.onPointerDown?.(e)) return;
|
||||
drag = { x: e.clientX, y: e.clientY, pan: e.button !== 0 || e.shiftKey };
|
||||
});
|
||||
c.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
window.addEventListener("mouseup", () => (drag = null));
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - drag.x;
|
||||
const dy = e.clientY - drag.y;
|
||||
drag.x = e.clientX;
|
||||
drag.y = e.clientY;
|
||||
if (drag.pan) {
|
||||
const s = (this.cam.dist * Math.tan(this.cam.fov / 2) * 2) / c.clientHeight;
|
||||
const yaw = this.cam.yaw;
|
||||
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
|
||||
const up = [-Math.sin(this.cam.pitch) * Math.sin(yaw), Math.cos(this.cam.pitch), -Math.sin(this.cam.pitch) * Math.cos(yaw)];
|
||||
for (let i = 0; i < 3; i++) this.cam.target[i] += -dx * s * right[i] + dy * s * up[i];
|
||||
} else {
|
||||
this.cam.yaw -= dx * 0.008;
|
||||
this.cam.pitch = Math.max(-1.55, Math.min(1.55, this.cam.pitch + dy * 0.008));
|
||||
}
|
||||
this.moved();
|
||||
});
|
||||
c.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
this.cam.dist = Math.max(0.5, this.cam.dist * (e.deltaY < 0 ? 0.88 : 1 / 0.88));
|
||||
this.moved();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
c.addEventListener("dblclick", (e) => !this.onPointerDown && this.reset());
|
||||
}
|
||||
|
||||
// ---------- drawing ----------
|
||||
draw() {
|
||||
const gl = this.gl;
|
||||
this.dirty = false;
|
||||
this.beforeDraw?.();
|
||||
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
const env = this.env;
|
||||
if (env?.clear) gl.clearColor(env.clear[0], env.clear[1], env.clear[2], 1);
|
||||
else gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.CULL_FACE);
|
||||
const vp = this.viewProj();
|
||||
const eye = this.eye();
|
||||
// meshes: opaque first, then blended
|
||||
const mp = this.meshProg;
|
||||
gl.useProgram(mp.p);
|
||||
gl.uniformMatrix4fv(mp.u.uVP, false, vp);
|
||||
gl.uniform3fv(mp.u.uEye, eye);
|
||||
gl.uniform1i(mp.u.uLit, this.opts.lit ? 1 : 0);
|
||||
gl.uniform1i(mp.u.uTex, 0);
|
||||
gl.uniform1i(mp.u.uLm, 1);
|
||||
gl.uniform1i(mp.u.uEnv, env?.ambient ? 1 : 0);
|
||||
if (env?.ambient) {
|
||||
gl.uniform3fv(mp.u.uAmbient, env.ambient);
|
||||
gl.uniform3fv(mp.u.uLightCol, env.lightColor || [0.6, 0.6, 0.6]);
|
||||
const d = norm(env.lightDir || [0, -1, 0]);
|
||||
gl.uniform3fv(mp.u.uLightDir, [d[0], d[1], -d[2]]);
|
||||
}
|
||||
gl.uniform1i(mp.u.uFog, env?.fog ? 1 : 0);
|
||||
if (env?.fog) {
|
||||
gl.uniform3fv(mp.u.uFogCol, env.fog.color);
|
||||
gl.uniform2fv(mp.u.uFogRange, [env.fog.start, env.fog.end]);
|
||||
}
|
||||
if (this.opts.solid) {
|
||||
for (const pass of [0, 1]) {
|
||||
if (pass) {
|
||||
// blended sub-meshes (decals, glows, shadows): after the opaque ones, no depth writes,
|
||||
// pulled slightly forward so coplanar layers don't flicker
|
||||
gl.enable(gl.BLEND);
|
||||
gl.depthMask(false);
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
gl.enable(gl.POLYGON_OFFSET_FILL);
|
||||
gl.polygonOffset(-1, -2);
|
||||
}
|
||||
for (const g of this.meshes.values()) {
|
||||
if (!g.visible) continue;
|
||||
gl.bindVertexArray(g.vao);
|
||||
gl.uniformMatrix4fv(mp.u.uModel, false, g.model);
|
||||
gl.uniform1i(mp.u.uHasNrm, g.hasNormal ? 1 : 0);
|
||||
for (const grp of g.groups) {
|
||||
const blended = grp.blend || (grp.color && grp.color[3] < 1);
|
||||
if (!!blended !== !!pass || !grp.count) continue;
|
||||
if (pass) this.blendMode(grp.blendFunc);
|
||||
if (grp.color) {
|
||||
gl.uniform1i(mp.u.uFlat, 1);
|
||||
gl.uniform4fv(mp.u.uColor, grp.color);
|
||||
gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4);
|
||||
continue;
|
||||
}
|
||||
gl.uniform1i(mp.u.uFlat, 0);
|
||||
const tex = this.opts.textures && grp.tex;
|
||||
const lm = this.opts.textures && grp.lmTex;
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, lm || this.white);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex || this.white);
|
||||
gl.uniform1i(mp.u.uHasTex, tex ? 1 : 0);
|
||||
gl.uniform1i(mp.u.uHasLm, lm ? 1 : 0);
|
||||
gl.uniform1i(mp.u.uUvSet, grp.uvSet || 0);
|
||||
gl.uniform1i(mp.u.uLmSet, grp.lmSet || 0);
|
||||
gl.uniform1i(mp.u.uLmAddDiffuse, grp.lmAddDiffuse ? 1 : 0);
|
||||
gl.uniform3fv(mp.u.uDiffuse, grp.diffuseColor || [1, 1, 1]);
|
||||
gl.uniform1i(mp.u.uOpaque, pass ? 0 : 1);
|
||||
gl.uniformMatrix3fv(mp.u.uTexM, false, grp.texM || IDENT3);
|
||||
gl.uniformMatrix3fv(mp.u.uLmM, false, grp.lmM || IDENT3);
|
||||
gl.uniform1f(mp.u.uColorMul, grp.colorMul ?? 1);
|
||||
gl.uniform1f(mp.u.uEmit, grp.emissive || 0);
|
||||
gl.uniform1f(mp.u.uAlphaCut, grp.blend ? grp.alphaCut ?? 0 : tex ? grp.alphaCut ?? 0.35 : 0);
|
||||
gl.uniform4fv(mp.u.uTint, grp.highlight || g.highlight ? [1.3, 1.15, 0.7, 1] : g.tint || [1, 1, 1, 1]);
|
||||
gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4);
|
||||
}
|
||||
}
|
||||
gl.disable(gl.BLEND);
|
||||
gl.depthMask(true);
|
||||
gl.depthFunc(gl.LESS);
|
||||
gl.disable(gl.POLYGON_OFFSET_FILL);
|
||||
gl.blendEquation(gl.FUNC_ADD);
|
||||
}
|
||||
}
|
||||
// particles: after the meshes, blended, no depth writes
|
||||
if (this.particles.length) {
|
||||
const pp = this.partProg;
|
||||
gl.useProgram(pp.p);
|
||||
gl.uniformMatrix4fv(pp.u.uVP, false, vp);
|
||||
gl.uniformMatrix4fv(pp.u.uModel, false, MIRROR);
|
||||
gl.uniform1i(pp.u.uTex, 0);
|
||||
gl.bindVertexArray(this.partVao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.depthMask(false);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
for (const b of this.particles) {
|
||||
if (!b.count) continue;
|
||||
if (b.depth) gl.enable(gl.DEPTH_TEST);
|
||||
else gl.disable(gl.DEPTH_TEST);
|
||||
this.blendMode(b.blend);
|
||||
gl.bindTexture(gl.TEXTURE_2D, b.tex || this.white);
|
||||
gl.uniform1i(pp.u.uHasTex, b.tex ? 1 : 0);
|
||||
gl.uniform1f(pp.u.uMod, b.mod2x ? 2 : 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, b.data, gl.STREAM_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, b.count);
|
||||
}
|
||||
gl.depthMask(true);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.BLEND);
|
||||
gl.blendEquation(gl.FUNC_ADD);
|
||||
gl.bindVertexArray(null);
|
||||
}
|
||||
// wireframe + overlays
|
||||
const lp = this.lineProg;
|
||||
gl.useProgram(lp.p);
|
||||
gl.uniformMatrix4fv(lp.u.uVP, false, vp);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.uniform1i(lp.u.uRound, 0);
|
||||
for (const g of this.meshes.values()) {
|
||||
if (!g.visible || (!this.opts.wire && !g.wireColor)) continue;
|
||||
const w = g.wireColor || [0.55, 0.85, 1, this.opts.solid ? 0.45 : 0.8];
|
||||
gl.vertexAttrib4f(1, w[0], w[1], w[2], w[3]); // constant colour (attribute 1 is off in the wire VAO)
|
||||
gl.uniformMatrix4fv(lp.u.uModel, false, g.model);
|
||||
gl.bindVertexArray(g.wvao);
|
||||
gl.drawElements(gl.LINES, g.wcount, gl.UNSIGNED_INT, 0);
|
||||
}
|
||||
gl.uniformMatrix4fv(lp.u.uModel, false, MIRROR);
|
||||
for (const o of this.overlays.values()) {
|
||||
if (!o.visible) continue;
|
||||
if (o.depth) gl.enable(gl.DEPTH_TEST);
|
||||
else gl.disable(gl.DEPTH_TEST);
|
||||
gl.uniform1f(lp.u.uPoint, o.point * (devicePixelRatio || 1));
|
||||
gl.uniform1i(lp.u.uRound, o.mode === "points" ? 1 : 0);
|
||||
gl.bindVertexArray(o.vao);
|
||||
gl.drawArrays(o.mode === "points" ? gl.POINTS : gl.LINES, 0, o.count);
|
||||
}
|
||||
gl.disable(gl.BLEND);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.bindVertexArray(null);
|
||||
}
|
||||
|
||||
/** Material scene_blend { op, src, dst } → GL state. Default: alpha blending. */
|
||||
blendMode(b) {
|
||||
const gl = this.gl;
|
||||
const F = { zero: gl.ZERO, one: gl.ONE, src_color: gl.SRC_COLOR, inv_src_color: gl.ONE_MINUS_SRC_COLOR, src_alpha: gl.SRC_ALPHA, inv_src_alpha: gl.ONE_MINUS_SRC_ALPHA, dest_alpha: gl.DST_ALPHA, inv_dest_alpha: gl.ONE_MINUS_DST_ALPHA, dest_color: gl.DST_COLOR, inv_dest_color: gl.ONE_MINUS_DST_COLOR };
|
||||
const O = { add: gl.FUNC_ADD, subtract: gl.FUNC_SUBTRACT, rev_subtract: gl.FUNC_REVERSE_SUBTRACT, min: gl.MIN, max: gl.MAX };
|
||||
gl.blendEquationSeparate(O[b?.op] ?? gl.FUNC_ADD, gl.FUNC_ADD);
|
||||
// Transparent views need accumulated alpha coverage. Effect preview uses
|
||||
// an opaque clear target and keeps the native RGB blend factors above.
|
||||
gl.blendFuncSeparate(F[b?.src] ?? gl.SRC_ALPHA, F[b?.dst] ?? gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
/** Screen position (CSS px) of a game-space point, or null behind the camera. */
|
||||
project(p) {
|
||||
const vp = this.viewProj();
|
||||
const x = p[0], y = p[1], z = -p[2];
|
||||
const cx = vp[0] * x + vp[4] * y + vp[8] * z + vp[12];
|
||||
const cy = vp[1] * x + vp[5] * y + vp[9] * z + vp[13];
|
||||
const cw = vp[3] * x + vp[7] * y + vp[11] * z + vp[15];
|
||||
if (cw <= 0) return null;
|
||||
return [((cx / cw + 1) / 2) * this.canvas.clientWidth, ((1 - cy / cw) / 2) * this.canvas.clientHeight];
|
||||
}
|
||||
}
|
||||
|
||||
return { View };
|
||||
})();
|
||||
window.GL3D = GL3D;
|
||||
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
// Edit one INI value without rewriting comments, unrelated keys, or line endings.
|
||||
const HeroIni = (() => {
|
||||
const split = (raw) => raw.split(/\r\n|\n|\r/);
|
||||
const ending = (raw) => raw.includes("\r\n") ? "\r\n" : raw.includes("\r") ? "\r" : "\n";
|
||||
function parse(raw) {
|
||||
const lines = split(raw), sections = [];
|
||||
let current = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const header = /^\s*\[([^\]]+)\]\s*(?:;.*)?$/.exec(lines[i]);
|
||||
if (header) {
|
||||
if (current) current.end = i;
|
||||
current = { name: header[1].trim(), start: i, end: lines.length, fields: [] };
|
||||
sections.push(current);
|
||||
continue;
|
||||
}
|
||||
const m = /^\s*([^\s=;#]+)\s*=\s*(.*)$/.exec(lines[i]);
|
||||
if (m && current) current.fields.push({ key: m[1], value: m[2].trim(), line: i });
|
||||
}
|
||||
return { raw, lines, sections };
|
||||
}
|
||||
function section(doc, name) { return doc.sections.find((s) => s.name.toLowerCase() === name.toLowerCase()); }
|
||||
function get(doc, name, key) {
|
||||
const s = section(doc, name);
|
||||
return s?.fields.findLast((f) => f.key.toLowerCase() === key.toLowerCase())?.value ?? "";
|
||||
}
|
||||
function set(raw, name, key, value) {
|
||||
const doc = parse(raw), s = section(doc, name);
|
||||
if (!s) throw new Error(`Section [${name}] is missing`);
|
||||
const field = s.fields.findLast((f) => f.key.toLowerCase() === key.toLowerCase());
|
||||
const lines = doc.lines;
|
||||
if (field) {
|
||||
const old = lines[field.line], prefix = /^\s*[^\s=;#]+\s*=\s*/.exec(old)?.[0] || `${key} = `;
|
||||
lines[field.line] = prefix + value;
|
||||
} else {
|
||||
lines.splice(s.end, 0, `${key} = ${value}`);
|
||||
}
|
||||
return lines.join(ending(raw));
|
||||
}
|
||||
return { parse, section, get, set };
|
||||
})();
|
||||
if (typeof module !== "undefined") module.exports = HeroIni;
|
||||
@@ -0,0 +1,847 @@
|
||||
"use strict";
|
||||
|
||||
// Experimental editor for the existing mercenary INIs. Edits are exported as loose files.
|
||||
(() => {
|
||||
const HERO = { src: null, rows: [], id: "", name: "", docs: {}, tab: "gear", assetRows: {}, previewReady: false, actionToken: 0,
|
||||
practice: { active: false, keys: new Set(), pos: [0, 0, 0], yaw: 0, vy: 0, grounded: true, dashUntil: 0, hp: 100, target: [0, 0, 220], combo: 0, comboAt: 0, directions: [], lastTap: {}, lastMotion: "", busy: false, chord: false } };
|
||||
window.HERO = HERO;
|
||||
HERO.openTab = (t) => document.querySelector(`[data-hero-tab="${t}"]`)?.click();
|
||||
const kinds = ["item", "skill", "attack", "buff"];
|
||||
const raw = (bytes) => {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192));
|
||||
return out;
|
||||
};
|
||||
const doc = (kind) => HERO.docs[kind] && HeroIni.parse(HERO.docs[kind].text);
|
||||
const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : ""; };
|
||||
// web fix: STR(n) → the text the game shows (text table key ini_<file stem>::<section>_<n>)
|
||||
const shown = (kind, section, v) => {
|
||||
const m = /^\s*STR\(\s*(\d+)/i.exec(v || "");
|
||||
return m ? HERO.strs?.[`ini_${HERO.id}_${kind}::${section}_${m[1]}`.toLowerCase()] || v : v;
|
||||
};
|
||||
const strText = (kind, section, key) => shown(kind, section, value(kind, section, key));
|
||||
const dirty = () => kinds.filter((kind) => HERO.docs[kind]?.text !== HERO.docs[kind]?.saved);
|
||||
const opt = (value, title, sub = "") => ({ value, title, sub });
|
||||
|
||||
function updateStatus() {
|
||||
const changed = dirty();
|
||||
$("hero-dirty").hidden = !changed.length;
|
||||
$("hero-export").disabled = !changed.length;
|
||||
$("hero-export-status").textContent = changed.length
|
||||
? `Ready to export: ${changed.map((x) => `${HERO.id}_${x}.ini`).join(", ")}`
|
||||
: "Only changed INI files will be exported. The game packs remain untouched.";
|
||||
}
|
||||
async function setSource(src) {
|
||||
setPractice(false);
|
||||
const d = await call("mdl:describe", src);
|
||||
HERO.src = { ...src, label: d.label };
|
||||
HERO.rows = await call("hero:list", HERO.src);
|
||||
HERO.docs = {}; HERO.id = ""; HERO.name = "";
|
||||
HERO.previewReady = false;
|
||||
$("hero-src-label").textContent = HERO.src.kind === "dir" ? "Extracted folder" : HERO.src.kind === "packs" ? "Pack folder" : "Profile packs";
|
||||
$("hero-title").textContent = "Hero Creator · experimental";
|
||||
$("hero-work").hidden = true; $("hero-empty").hidden = false;
|
||||
$("hero-preview").disabled = true;
|
||||
HERO.assetRows = {};
|
||||
renderList(); updateStatus();
|
||||
}
|
||||
async function chooseSource() {
|
||||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||||
const options = [
|
||||
...profileSourceOption(),
|
||||
...sessions.map((s) => opt({ kind: "session", sessionId: s.id, name: s.name }, `Packs next to ${s.name}`, s.file)),
|
||||
opt("packs", "Folder with .iop packs…", "Client folder with rs.iop and resource packs"),
|
||||
opt("dir", "Extracted folder…", "Folder containing config/mercenary"),
|
||||
];
|
||||
if (!options.length) return toast("No asset source found.", true);
|
||||
let choice = options[0].value;
|
||||
const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [
|
||||
el("input", { type: "radio", name: "herosrc", checked: i === 0, onchange: () => { choice = o.value; } }),
|
||||
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
|
||||
])));
|
||||
if (!(await modal("Hero source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return;
|
||||
if (choice === "packs" || choice === "dir") {
|
||||
const folder = await call("dialog:folder", "Choose the game resource folder");
|
||||
if (!folder) return;
|
||||
choice = { kind: choice, folder };
|
||||
}
|
||||
try { await setSource(choice); } catch (err) { toast(err.message, true); }
|
||||
}
|
||||
$("hero-src").onclick = chooseSource;
|
||||
|
||||
function renderList() {
|
||||
const q = $("hero-search").value.trim().toLowerCase();
|
||||
const rows = HERO.rows.filter(([id, name]) => !q || id.includes(q) || name.toLowerCase().includes(q));
|
||||
$("hero-count").textContent = String(rows.length);
|
||||
$("hero-list").replaceChildren(...rows.map(([id, name]) => el("button", {
|
||||
class: `hero-row${id === HERO.id ? " on" : ""}`, onclick: () => openHero(id, name),
|
||||
}, [el("span", { class: "hero-id", text: id }), el("span", { class: "hero-row-name", text: name })])));
|
||||
}
|
||||
$("hero-search").oninput = renderList;
|
||||
HERO.setSource = (src) => setSource(src);
|
||||
HERO.open = (id, name) => openHero(id, name || id);
|
||||
|
||||
async function openHero(id, name) {
|
||||
if (dirty().length && !(await confirmBox("Unsaved Hero Creator changes", "Discard your current edits?", "Discard", "danger"))) return;
|
||||
try {
|
||||
setPractice(false);
|
||||
const docs = {};
|
||||
for (const kind of kinds) {
|
||||
const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_${kind}.ini`);
|
||||
if (hit) docs[kind] = { text: raw(hit.bytes), saved: raw(hit.bytes) };
|
||||
}
|
||||
if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);
|
||||
try { HERO.strs = await call("text:forStems", HERO.src, Object.keys(docs).map((k) => `${id}_${k}`)); } catch { HERO.strs = {}; }
|
||||
HERO.id = id; HERO.name = name; HERO.docs = docs; HERO.tab = "gear";
|
||||
HERO.previewReady = false;
|
||||
$("hero-title").textContent = `${id} · ${name}`;
|
||||
$("hero-name").textContent = `${id} · ${name}`;
|
||||
$("hero-summary").textContent = "Edit gear appearance, move animations, skills and effect references. Export writes loose INI files for testing.";
|
||||
$("hero-work").hidden = false; $("hero-empty").hidden = true;
|
||||
$("hero-preview").disabled = false;
|
||||
$("hero-practice").disabled = false;
|
||||
for (const b of document.querySelectorAll("[data-hero-tab]")) b.classList.toggle("on", b.dataset.heroTab === "gear");
|
||||
renderList(); renderContent(); renderActions(); updateStatus();
|
||||
buildPreview();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
for (const b of document.querySelectorAll("[data-hero-tab]")) b.onclick = () => {
|
||||
HERO.tab = b.dataset.heroTab;
|
||||
for (const x of document.querySelectorAll("[data-hero-tab]")) x.classList.toggle("on", x === b);
|
||||
renderContent();
|
||||
};
|
||||
|
||||
function edit(kind, section, key, newValue) {
|
||||
if (/[\r\n]/.test(newValue) || [...newValue].some((c) => c.charCodeAt(0) > 255)) return toast("Use a single-line 8-bit INI value.", true);
|
||||
const d = HERO.docs[kind];
|
||||
d.text = HeroIni.set(d.text, section, key, newValue);
|
||||
if (kind === "item" || kind === "skill" || kind === "attack") renderActions();
|
||||
if (kind === "item" && isMesh(key) && /^(?:equip|body)_mesh/i.test(key)) {
|
||||
HERO.previewReady = false;
|
||||
clearTimeout(HERO.previewTimer);
|
||||
HERO.previewTimer = setTimeout(buildPreview, 250);
|
||||
}
|
||||
updateStatus();
|
||||
}
|
||||
const isMesh = (key) => /^(equip_mesh\d*|body_mesh\d*|field_mesh\d*|collision_mesh|mesh_file)$/i.test(key);
|
||||
const isMove = (_key, val) => /\.ani(?:\s|$)/i.test(val);
|
||||
const isFx = (key, val) => /effect/i.test(key) && /\.txt(?:\s|$)/i.test(val);
|
||||
const assetType = (key, val) => isMesh(key) ? "mesh" : /\.ani(?:\s|$)/i.test(val) ? "animation" : /\.txt(?:\s|$)/i.test(val) ? "effect" : "";
|
||||
|
||||
function fieldRow(kind, section, f) {
|
||||
const input = el("input", { type: "text", value: f.value, spellcheck: "false", title: `${kind}.ini [${section}] ${f.key}` });
|
||||
input.addEventListener("change", () => edit(kind, section, f.key, input.value));
|
||||
input.addEventListener("focus", () => {
|
||||
$("hero-field-detail").textContent = `${HERO.id}_${kind}.ini · [${section}] · ${f.key}`;
|
||||
});
|
||||
const type = assetType(f.key, f.value);
|
||||
const buttons = [];
|
||||
if (type) buttons.push(el("button", { class: "iconbtn only", title: `Choose ${type}`, text: "…", onclick: async () => {
|
||||
const picked = await pickAsset(type, input.value);
|
||||
if (picked) { input.value = picked; edit(kind, section, f.key, picked); }
|
||||
}}));
|
||||
if (type) buttons.push(el("button", { class: "iconbtn only", title: `Preview ${type}`, text: "View", onclick: () => previewAsset(type, input.value) }));
|
||||
return el("div", { class: "hero-field" }, [el("label", { text: f.key }), input, el("span", {}, buttons)]);
|
||||
}
|
||||
function card(title, fields, opts = {}) {
|
||||
const head = el("div", { class: "hero-card-head" }, [el("h3", { text: title }), el("div", { class: "spacer" }), ...(opts.actions || [])]);
|
||||
return el("section", { class: "hero-card" }, [head, el("div", { class: "hero-fields" }, fields)]);
|
||||
}
|
||||
const itemSections = () => doc("item")?.sections.filter((s) => /^item\d+$/i.test(s.name)) || [];
|
||||
|
||||
function renderGear() {
|
||||
return itemSections().map((s) => {
|
||||
const name = strText("item", s.name, "name");
|
||||
const slot = value("item", s.name, "item_type") || s.name;
|
||||
const fields = s.fields.filter((f) => isMesh(f.key));
|
||||
const donor = el("button", { class: "iconbtn", text: "Copy appearance…", onclick: () => copyAppearance(s.name) });
|
||||
return card(`${slot} · ${name}`, fields.map((f) => fieldRow("item", s.name, f)), { actions: [donor] });
|
||||
});
|
||||
}
|
||||
function renderMoves() {
|
||||
const cards = [];
|
||||
for (const s of itemSections()) {
|
||||
const fields = s.fields.filter((f) => isMove(f.key, f.value));
|
||||
if (fields.length) cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${strText("item", s.name, "name")}`, fields.map((f) => fieldRow("item", s.name, f))));
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
function renderSkills() {
|
||||
const d = doc("skill");
|
||||
if (!d) return [];
|
||||
return d.sections.filter((s) => /^skill\d+$/i.test(s.name)).map((s) => {
|
||||
const fields = s.fields.filter((f) => isMove(f.key, f.value) || isFx(f.key, f.value) || /^(name|desc_name|need_gauge|type|sub_type)$/i.test(f.key));
|
||||
return card(`${s.name} · ${strText("skill", s.name, "desc_name") || strText("skill", s.name, "name")}`, fields.map((f) => fieldRow("skill", s.name, f)));
|
||||
});
|
||||
}
|
||||
function renderEffects() {
|
||||
const cards = [];
|
||||
for (const kind of kinds) {
|
||||
const d = doc(kind);
|
||||
if (!d) continue;
|
||||
for (const s of d.sections) {
|
||||
const fields = s.fields.filter((f) => isFx(f.key, f.value));
|
||||
if (fields.length) cards.push(card(`${kind.toUpperCase()} · [${s.name}]`, fields.map((f) => fieldRow(kind, s.name, f))));
|
||||
}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
// =====================================================================
|
||||
// Flow: input → animation (.ani events) → hits (fire_s n ↔ attack_type n) → [attributeN] → effects /
|
||||
// sounds / buffs, with the checks the client would fail on (ioBaseChar::RefreshFireTimeListWithBase,
|
||||
// ioPlayStage::AddWeaponExplosionEffect, ioClientAniEventHandler)
|
||||
// =====================================================================
|
||||
const FLOW_LABELS = { normal_attack: "D combo", dash_attack: "Dash + D", extra_dash_attack: "Dash + D (extra)", extend_dash_attack: "Dash + D (extended)", jump_attack: "Air + D", extend_jump_attack: "Air + D (extended)", land_jump_attack: "Landing + D", counter_attack: "Counter", extend_attack: "Hold D", normal_charged: "Charged D", jump_charged: "Charged air D", dash_jump_attack: "Dash jump + D", retreat_attack: "Retreat attack", attack: "Skill", jump_attack_skill: "Skill in the air" };
|
||||
const ATTR_TYPES = { 0: "dummy", 1: "contact (hitbox)", 2: "missile", 3: "throw", 4: "bomb", 5: "mine" };
|
||||
const flowNames = new Map();
|
||||
function flowList(folder) {
|
||||
const key = `${folder}|${HERO.src?.folder || HERO.src?.sessionId || ""}`;
|
||||
if (!flowNames.has(key)) flowNames.set(key, call("mdl:names", HERO.src, folder).then((l) => new Set(l.map((n) => n.toLowerCase().split("/").pop()))).catch(() => new Set()));
|
||||
return flowNames.get(key);
|
||||
}
|
||||
const fname = (v) => String(v || "").replace(/\s*;.*$/, "").trim().split(/\s+/)[0];
|
||||
/** Every attacking action of the hero: { kind, section, label, ani, rate, types: [{ id, wounded }] } */
|
||||
function flowActions() {
|
||||
const out = [];
|
||||
for (const kind of ["item", "skill"]) {
|
||||
const d = doc(kind);
|
||||
if (!d) continue;
|
||||
for (const s of d.sections) {
|
||||
const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value);
|
||||
const title = kind === "skill" ? `${s.name} · ${get("desc_name") && !/^STR\(/.test(get("desc_name")) ? get("desc_name") : get("name") || ""}` : `${get("item_type") || s.name} · ${s.name}`;
|
||||
for (const f of s.fields) {
|
||||
const m = /^(.*)_type_count$/i.exec(f.key);
|
||||
if (!m) continue;
|
||||
const p = m[1], count = Number.parseInt(fname(f.value), 10) || 0;
|
||||
const aniKey = kind === "skill" && /^attack$/i.test(p) ? "skill_motion" : kind === "skill" && /^jump_attack$/i.test(p) ? "jump_skill_motion" : p;
|
||||
const ani = get(aniKey);
|
||||
if (!/\.ani$/i.test(ani)) continue;
|
||||
const rate = Number.parseFloat(get(`${p}_time_rate`) || (kind === "skill" ? get(aniKey === "jump_skill_motion" ? "jump_animation_time_rate" : "animation_time_rate") : "") || "1") || 1;
|
||||
const types = Array.from({ length: count }, (_, i) => ({ id: get(`${p}_type${i + 1}`), wounded: get(`${p}_type${i + 1}_wounded`) }));
|
||||
const base = p.replace(/\d+$/, ""), num = (/(\d+)$/.exec(p) || [])[1];
|
||||
const label = kind === "skill" ? `${title}${/^jump_attack$/i.test(p) ? " (in the air)" : /^attack$/i.test(p) ? "" : ` (${p})`}` : `${FLOW_LABELS[base] || p}${num ? ` ${Number(num)}` : ""}`;
|
||||
out.push({ kind, section: s.name, title, label, key: p, aniKey, ani, rate, types, preDelay: kind === "skill" ? get("pre_delay_animation") : "", preEffect: kind === "skill" ? get("pre_delay_effect") : "", gauge: kind === "skill" ? get("need_gauge") : "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function flowAttr(id) {
|
||||
const d = doc("attack");
|
||||
const s = d && HeroIni.section(d, `attribute${id}`);
|
||||
if (!s) return null;
|
||||
const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value).replace(/^(-?[0-9.]+)f$/i, "$1");
|
||||
const buffs = Array.from({ length: Number.parseInt(get("target_buff_cnt"), 10) || 0 }, (_, i) => get(`target_buff${i + 1}`)).filter(Boolean);
|
||||
return { id, type: Number(get("type") || 0), damage: get("damage_rate"), push: get("push_power"), blow: get("blow_power"), airBlow: get("air_blow_power"),
|
||||
explosion: get("explosion_effect"), extra: get("extra_explosion_effect"), weaponEffect: get("weapon_effect"), wounded: get("wounded_sound"), defensed: get("defensed_sound"),
|
||||
buffs, breakBuff: get("defense_break_buff"), shake: get("shake_camera") };
|
||||
}
|
||||
const aniCache = new Map();
|
||||
async function flowAni(name) {
|
||||
const key = name.toLowerCase();
|
||||
if (!aniCache.has(key)) aniCache.set(key, call("mdl:read", HERO.src, name, "animation", true).then((h) => (h ? LSF.parseAni(h.bytes) : null)).catch(() => null));
|
||||
return aniCache.get(key);
|
||||
}
|
||||
let flowAudio = null;
|
||||
async function flowPlay(path) {
|
||||
try {
|
||||
const h = await call("mdl:readPath", HERO.src, `wave/${path}`) || await call("mdl:readPath", HERO.src, `wave/${path.replace(/(\.\w+)$/, "_1$1")}`);
|
||||
if (!h) return toast(`${path} is not in the packs`, true);
|
||||
flowAudio ??= new AudioContext();
|
||||
const b = await flowAudio.decodeAudioData(h.bytes.slice().buffer);
|
||||
const s = flowAudio.createBufferSource(); s.buffer = b; s.connect(flowAudio.destination); s.start();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
async function flowOpenAni(name) {
|
||||
document.querySelector('[data-view="ans"]').click();
|
||||
if (!window.ANS?.setSource) return;
|
||||
if (ANS.src?.folder !== HERO.src.folder || ANS.src?.kind !== HERO.src.kind) await ANS.setSource(HERO.src, { quiet: true });
|
||||
for (let i = 0; i < 50 && !(ANS.rows && ANS.rows.length); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
const row = ANS.rows.find((r) => r[3] === "ani" && `${r[1]}.ani`.toLowerCase() === name.toLowerCase());
|
||||
if (!row) return toast(`${name} is not in the Anim Studio list`, true);
|
||||
await ANS.openRow(row);
|
||||
if (ANS.setHit) ANS.setHit({ hero: HERO.id, weapon: "" });
|
||||
}
|
||||
async function flowOpenFx(name) {
|
||||
try {
|
||||
const h = await call("mdl:read", HERO.src, name, "effect", true);
|
||||
if (!h) return toast(`${name} is not in the packs`, true);
|
||||
let text = "";
|
||||
for (let i = 0; i < h.bytes.length; i += 8192) text += String.fromCharCode(...h.bytes.subarray(i, i + 8192));
|
||||
document.querySelector('[data-view="fxlab"]').click();
|
||||
FXL.openScript(text, name, h.where);
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
const chip = (text, cls = "", attrs = {}) => el("span", { class: `flow-chip ${cls}`, text, ...attrs });
|
||||
function fileChip(name, kind, exists) {
|
||||
if (!name || /^none$/i.test(name)) return null;
|
||||
const missing = exists && !exists.has(name.toLowerCase().split("/").pop()) && !(kind === "sound" && exists.has(name.toLowerCase().split("/").pop().replace(/(\.\w+)$/, "_1$1")));
|
||||
const kids = [el("span", { text: name })];
|
||||
if (kind === "sound") kids.push(el("button", { class: "flow-mini", title: "Listen", onclick: () => flowPlay(name) }, [icon("play")]));
|
||||
if (kind === "effect") kids.push(el("button", { class: "flow-mini", title: "Open in FX Lab", onclick: () => flowOpenFx(name) }, [icon("brush")]));
|
||||
return el("span", { class: `flow-chip file ${kind}${missing ? " bad" : ""}`, title: missing ? `${name}: not in the packs` : name }, kids);
|
||||
}
|
||||
function renderFlow() {
|
||||
const box = el("div", { class: "flow" }, [el("div", { class: "muted", text: "Reading the animations…" })]);
|
||||
const seq = (HERO.flowSeq = (HERO.flowSeq || 0) + 1);
|
||||
(async () => {
|
||||
const [effects, waves] = await Promise.all([flowList("effect"), flowList("wave")]);
|
||||
const acts = flowActions();
|
||||
const cards = [];
|
||||
let problems = 0;
|
||||
for (const a of acts) {
|
||||
const data = await flowAni(a.ani);
|
||||
if (seq !== HERO.flowSeq) return;
|
||||
const ev = (data?.events || []).map((e) => ({ t: e.time, type: e.type.text.toLowerCase(), info: e.sub.text.trim() })).sort((x, y) => x.t - y.t);
|
||||
const starts = ev.filter((e) => e.type === "fire_s"), ends = ev.filter((e) => e.type === "fire_e");
|
||||
const ok = data && starts.length === a.types.length;
|
||||
if (!ok) problems++;
|
||||
const rateT = (t) => Math.round(t * a.rate);
|
||||
const others = ev.filter((e) => !/^fire_[se]$/.test(e.type) && /sound|effect|face_change|trail_s|auto_shake/.test(e.type));
|
||||
const hits = a.types.map((ty, i) => {
|
||||
const s = starts[i], e = ends[i], at = flowAttr(ty.id);
|
||||
const box2 = s ? (s.info.split(/\s+/)[0] || "front of the body") : "—";
|
||||
return el("div", { class: `flow-hit${!s ? " bad" : ""}` }, [
|
||||
el("div", { class: "flow-hit-head" }, [
|
||||
chip(`hit ${i + 1}`, "n"),
|
||||
chip(s ? `${rateT(s.t)}–${rateT(e ? e.t : s.t)} ms` : "no fire_s", s ? "" : "bad"),
|
||||
chip(box2, "box"),
|
||||
el("span", { class: "flow-arrow", text: "→" }),
|
||||
chip(`attribute${ty.id}`, at ? "attr" : "bad"),
|
||||
at ? chip(ATTR_TYPES[at.type] || `type ${at.type}`) : null,
|
||||
at?.damage ? chip(`damage ×${at.damage}`) : null,
|
||||
at && (at.push || at.blow) ? chip(`push ${at.push || 0} · blow ${at.blow || 0}${at.airBlow ? ` · air ${at.airBlow}` : ""}`) : null,
|
||||
]),
|
||||
at ? el("div", { class: "flow-hit-body" }, [
|
||||
at.explosion ? el("span", { class: "flow-l" }, [el("em", { text: "on target" }), fileChip(at.explosion, "effect", effects)]) : null,
|
||||
at.extra ? el("span", { class: "flow-l" }, [el("em", { text: "attacker only" }), fileChip(at.extra, "effect", effects)]) : null,
|
||||
at.weaponEffect ? el("span", { class: "flow-l" }, [el("em", { text: "projectile" }), fileChip(at.weaponEffect, "effect", effects)]) : null,
|
||||
at.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "hit sound" }), fileChip(at.wounded, "sound", waves)]) : null,
|
||||
at.defensed ? el("span", { class: "flow-l" }, [el("em", { text: "blocked sound" }), fileChip(at.defensed, "sound", waves)]) : null,
|
||||
...at.buffs.map((b) => el("span", { class: "flow-l" }, [el("em", { text: "buff on target" }), chip(b, "buff")])),
|
||||
at.breakBuff ? el("span", { class: "flow-l" }, [el("em", { text: "guard break" }), chip(at.breakBuff, "buff")]) : null,
|
||||
ty.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "target plays" }), chip(ty.wounded)]) : null,
|
||||
]) : el("div", { class: "flow-hit-body bad", text: `[attribute${ty.id}] is not in ${HERO.id}_attack.ini` }),
|
||||
]);
|
||||
});
|
||||
if (starts.length > a.types.length) hits.push(el("div", { class: "flow-hit bad" }, [chip(`${starts.length - a.types.length} extra fire_s without an attack type`, "bad")]));
|
||||
const evChips = others.map((e) => {
|
||||
const kind = /sound/.test(e.type) ? "sound" : /effect/.test(e.type) ? "effect" : "";
|
||||
return el("span", { class: "flow-l" }, [el("em", { text: `${rateT(e.t)} ms ${e.type}` }), kind ? fileChip(e.info.split(/\s+/)[0], kind, kind === "sound" ? waves : effects) : chip(e.info || "—")]);
|
||||
});
|
||||
cards.push(el("section", { class: `flow-card${ok ? "" : " bad"}` }, [
|
||||
el("div", { class: "flow-head" }, [
|
||||
el("b", { text: a.label }),
|
||||
el("span", { class: "muted", text: `${a.title} · ${a.kind}.ini ${a.key}` }),
|
||||
el("div", { class: "spacer" }),
|
||||
el("span", { class: `flow-check ${ok ? "ok" : "bad"}`, text: !data ? "✗ animation not in the packs" : ok ? `✓ ${starts.length} fire_s = ${a.types.length} attack types` : `✗ ${starts.length} fire_s ≠ ${a.types.length} attack types (the attack never fires)` }),
|
||||
]),
|
||||
el("div", { class: "flow-row" }, [
|
||||
a.preDelay ? el("div", { class: "flow-step" }, [el("em", { text: "ready" }), chip(a.preDelay), a.preEffect ? fileChip(a.preEffect, "effect", effects) : null]) : null,
|
||||
a.preDelay ? el("span", { class: "flow-arrow", text: "→" }) : null,
|
||||
el("div", { class: "flow-step" }, [
|
||||
el("em", { text: `animation${a.rate !== 1 ? ` ×${a.rate}` : ""}${data ? ` · ${rateT(data.total || 0)} ms` : ""}` }),
|
||||
el("span", { class: `flow-chip ani${data ? "" : " bad"}` }, [el("span", { text: a.ani }), el("button", { class: "flow-mini", title: "Open in Anim Studio (with the hitbox check)", onclick: () => flowOpenAni(a.ani) }, [icon("bone")])]),
|
||||
]),
|
||||
evChips.length ? el("span", { class: "flow-arrow", text: "·" }) : null,
|
||||
evChips.length ? el("div", { class: "flow-step flow-events" }, evChips) : null,
|
||||
]),
|
||||
el("div", { class: "flow-hits" }, hits.length ? hits : [el("div", { class: "muted", text: "No attack types." })]),
|
||||
]));
|
||||
}
|
||||
if (seq !== HERO.flowSeq) return;
|
||||
box.replaceChildren(
|
||||
el("div", { class: `flow-summary ${problems ? "bad" : "ok"}`, text: `${acts.length} attacking actions · ${problems ? `${problems} with a fire_s / attack type mismatch or a missing animation` : "all fire_s counts match"}. Red files are not in the packs.` }),
|
||||
...cards,
|
||||
);
|
||||
})().catch((err) => box.replaceChildren(el("div", { class: "bad", text: err.message })));
|
||||
return [box];
|
||||
}
|
||||
function renderContent() {
|
||||
if (!HERO.id) return;
|
||||
const cards = HERO.tab === "gear" ? renderGear() : HERO.tab === "moves" ? renderMoves() : HERO.tab === "skills" ? renderSkills() : HERO.tab === "flow" ? renderFlow() : renderEffects();
|
||||
$("hero-content").replaceChildren(...(cards.length ? cards : [el("div", { class: "hero-empty-note", text: "No matching fields in this hero's config." })]));
|
||||
}
|
||||
|
||||
async function copyAppearance(section) {
|
||||
const idInput = el("input", { type: "text", placeholder: "Hero ID, e.g. 052", spellcheck: "false" });
|
||||
if (!(await modal("Copy gear appearance", [
|
||||
el("p", { text: "Copy mesh references from the same gear slot of another hero. Item codes and combat data stay with the base hero." }),
|
||||
el("label", { text: `Source hero for ${section}` }), idInput,
|
||||
], [{ label: "Cancel", value: false }, { label: "Copy meshes", kind: "primary", value: true }]))) return;
|
||||
const id = idInput.value.trim().padStart(3, "0");
|
||||
if (!/^\d{3}$/.test(id)) return toast("Enter a 3-digit hero ID.", true);
|
||||
try {
|
||||
const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_item.ini`);
|
||||
if (!hit) throw new Error(`Hero ${id} gear config was not found`);
|
||||
const donor = HeroIni.parse(raw(hit.bytes)), s = HeroIni.section(donor, section);
|
||||
if (!s) throw new Error(`Donor has no [${section}]`);
|
||||
const meshFields = s.fields.filter((f) => isMesh(f.key));
|
||||
if (!meshFields.length) throw new Error(`Donor [${section}] has no mesh references`);
|
||||
for (const f of meshFields) edit("item", section, f.key, f.value);
|
||||
renderContent(); toast(`Copied ${meshFields.length} mesh fields from hero ${id}.`);
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
|
||||
async function assetsFor(type) {
|
||||
if (HERO.assetRows[type]) return HERO.assetRows[type];
|
||||
const rows = type === "effect" ? await call("fx:list", HERO.src) : await call("mdl:list", HERO.src);
|
||||
HERO.assetRows[type] = rows.map((r) => r[1].split("/").pop()).filter((name) =>
|
||||
type === "effect" ? /\.txt$/i.test(name) : type === "animation" ? /\.ani$/i.test(name) : /\.msh$/i.test(name));
|
||||
return HERO.assetRows[type];
|
||||
}
|
||||
async function pickAsset(type, current) {
|
||||
try {
|
||||
const rows = await assetsFor(type);
|
||||
const search = el("input", { type: "search", value: current.replace(/\.[^.]+$/, ""), placeholder: `Search ${type}s`, spellcheck: "false" });
|
||||
const list = el("div", { class: "hero-picker-list" });
|
||||
let chosen = "";
|
||||
const render = () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
list.replaceChildren(...rows.filter((x) => !q || x.toLowerCase().includes(q)).slice(0, 100).map((name) => el("button", {
|
||||
class: `hero-picker-row${name === chosen ? " on" : ""}`, text: name,
|
||||
onclick: () => { chosen = name; render(); }, ondblclick: () => { chosen = name; $("modal-actions").lastChild.click(); },
|
||||
})));
|
||||
};
|
||||
search.oninput = render; render();
|
||||
const ok = await modal(`Choose ${type}`, [search, list], [{ label: "Cancel", value: false }, { label: "Use selected", kind: "primary", value: true }]);
|
||||
return ok ? chosen : "";
|
||||
} catch (err) { toast(err.message, true); return ""; }
|
||||
}
|
||||
async function previewAsset(type, name) {
|
||||
if (!name) return;
|
||||
try {
|
||||
if (type === "effect") {
|
||||
await FXE.previewAsset(HERO.src, name);
|
||||
} else if (type === "animation") {
|
||||
await MDL.previewHero(HERO.src, gearMeshes(), name, { hair: helmetHair() });
|
||||
} else {
|
||||
await MDL.previewAsset(HERO.src, name, type);
|
||||
}
|
||||
} catch (err) { toast(err.message, true); }
|
||||
}
|
||||
// web fix: the helmet section (item_type HELMET) and its hair rule for the man body (default 0 = half hair)
|
||||
function helmetHair() {
|
||||
const s = itemSections().find((x) => /^helmet$/i.test(value("item", x.name, "item_type")) && value("item", x.name, "equip_mesh0"));
|
||||
if (!s) return null;
|
||||
const v = value("item", s.name, "hair_change_all0_hm");
|
||||
return v === "" ? 0 : Number(v);
|
||||
}
|
||||
const gearMeshes = () => itemSections().flatMap((s) => s.fields.filter((f) => /^(?:equip|body)_mesh\d*$/i.test(f.key)).map((f) => f.value)).filter(Boolean);
|
||||
async function buildPreview() {
|
||||
if (!HERO.id) return;
|
||||
const meshes = gearMeshes();
|
||||
const animation = value("item", "item1", "delay_animation") || value("item", "item1", "normal_attack01");
|
||||
$("hero-preview-status").textContent = "Loading body, gear and animation…";
|
||||
$("hero-play-status").textContent = "Loading hero…";
|
||||
try {
|
||||
await MDL.previewHero(HERO.src, meshes, animation, { embedded: true, hair: helmetHair() });
|
||||
if (HERO.practice.active) { MDL.setHeroTransform(HERO.practice.pos, HERO.practice.yaw); MDL.setHeroPracticeTarget(HERO.practice.target); }
|
||||
HERO.previewReady = true;
|
||||
$("hero-preview-status").textContent = `${meshes.length} gear meshes · ${animation || "default pose"}`;
|
||||
$("hero-play-status").textContent = "Ready · drag to rotate, wheel to zoom";
|
||||
} catch (err) { HERO.previewReady = false; $("hero-preview-status").textContent = err.message; $("hero-play-status").textContent = "Preview unavailable"; toast(err.message, true); }
|
||||
}
|
||||
$("hero-preview").onclick = buildPreview;
|
||||
const ani = (s) => /\.ani$/i.test(String(s || "").trim()) ? String(s).trim() : "";
|
||||
const fx = (s) => /\.txt$/i.test(String(s || "").trim()) ? String(s).trim() : "";
|
||||
function attributeSummary(index) {
|
||||
if (!index || !doc("attack")) return "";
|
||||
const section = `attribute${index}`;
|
||||
if (!HeroIni.section(doc("attack"), section)) return `attack ${index}`;
|
||||
const damage = value("attack", section, "damage_rate");
|
||||
const effect = fx(value("attack", section, "explosion_effect")) || fx(value("attack", section, "extra_explosion_effect"));
|
||||
return `attack ${index}${damage ? ` · damage ${damage}` : ""}${effect ? ` · ${effect}` : ""}`;
|
||||
}
|
||||
function attackRefs(kind, section, prefix) {
|
||||
const count = Math.min(20, Number.parseInt(value(kind, section, `${prefix}_type_count`) || value(kind, section, "attack_type_count"), 10) || 0);
|
||||
return Array.from({ length: count }, (_, i) => value(kind, section, `${prefix}_type${i + 1}`) || value(kind, section, `attack_type${i + 1}`)).filter(Boolean);
|
||||
}
|
||||
function actionEffect(kind, section, refs, preferred = []) {
|
||||
for (const key of preferred) {
|
||||
const name = fx(value(kind, section, key));
|
||||
if (name) return name;
|
||||
}
|
||||
for (const index of refs) {
|
||||
const name = fx(value("attack", `attribute${index}`, "explosion_effect")) || fx(value("attack", `attribute${index}`, "extra_explosion_effect"));
|
||||
if (name) return name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function iconCanvas(name) {
|
||||
const canvas = el("canvas", { class: "hero-skill-icon", width: 40, height: 40, title: name || "No icon" });
|
||||
if (!name) return canvas;
|
||||
(async () => {
|
||||
try {
|
||||
await HERO.iconReady;
|
||||
if (!canvas.isConnected) return;
|
||||
const i = name.indexOf("#");
|
||||
const image = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name);
|
||||
if (!image?.bmp || !canvas.isConnected) return;
|
||||
const scale = Math.min(40 / image.w, 40 / image.h);
|
||||
const w = image.w * scale, h = image.h * scale;
|
||||
canvas.getContext("2d").drawImage(image.bmp, image.x, image.y, image.w, image.h, (40 - w) / 2, (40 - h) / 2, w, h);
|
||||
} catch (err) { console.warn(`Hero icon ${name}: ${err.message}`); }
|
||||
})();
|
||||
return canvas;
|
||||
}
|
||||
function makeAction(label, subtitle, action, iconName = "") {
|
||||
const children = iconName ? [iconCanvas(iconName)] : [];
|
||||
children.push(el("span", {}, [el("b", { text: label }), el("small", { text: subtitle || "" })]));
|
||||
const button = el("button", { class: "hero-action", onclick: (e) => runAction(action, e.currentTarget), title: action.description }, children);
|
||||
button.heroAction = action;
|
||||
return button;
|
||||
}
|
||||
function skillSpec(section, label) {
|
||||
const title = strText("skill", section, "desc_name") || strText("skill", section, "name");
|
||||
const animationKeys = ["pre_delay_animation", "skill_motion", "skill_loop_motion", "loop_animation", "skill_end_attack", "end_animation"];
|
||||
const animations = animationKeys.map((key) => ani(value("skill", section, key))).filter(Boolean);
|
||||
const refs = attackRefs("skill", section, "attack");
|
||||
const effect = actionEffect("skill", section, refs, ["pre_delay_all_effect", "skill_charging_effect", "pre_delay_effect", "skill_map_effect", "range_effect"]);
|
||||
const buffs = Array.from({ length: Math.min(12, Number.parseInt(value("skill", section, "owner_buff_cnt"), 10) || 0) }, (_, i) => value("skill", section, `owner_buff${i + 1}`)).filter(Boolean);
|
||||
const description = [title, value("skill", section, "desc"), animations.length ? "" : "Passive / no animation", ...animations, ...buffs.map((b) => `buff ${b}`), ...refs.map(attributeSummary)].filter(Boolean).join("\n");
|
||||
return { title, action: { label, animations, effect, refs, description } };
|
||||
}
|
||||
function renderActions() {
|
||||
if (HERO.iconSource !== HERO.src) {
|
||||
HERO.iconSource = HERO.src;
|
||||
HERO.iconReady = UIRes.load(HERO.src).catch((err) => console.warn(`Hero icons: ${err.message}`));
|
||||
}
|
||||
const weapon = "item1", max = Math.min(12, Number.parseInt(value("item", weapon, "max_combo"), 10) || 0);
|
||||
const normal = Array.from({ length: max }, (_, i) => {
|
||||
const key = `normal_attack${String(i + 1).padStart(2, "0")}`;
|
||||
return { key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) };
|
||||
}).filter((step) => step.name);
|
||||
const combos = normal.map((_, i) => {
|
||||
const steps = normal.slice(0, i + 1);
|
||||
const refs = steps.flatMap((s) => s.refs);
|
||||
const label = Array(i + 1).fill("D").join(" ");
|
||||
return makeAction(label, `${i + 1} hit${i ? "s" : ""}`, {
|
||||
label, animations: steps.map((s) => s.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs,
|
||||
description: `${steps.map((s) => s.name).join(" → ")}\n${refs.map(attributeSummary).join("\n")}`,
|
||||
});
|
||||
});
|
||||
// The client has many item subclasses with their own attack families. Use
|
||||
// the animation-bearing config fields so no subclass needs a hardcoded list.
|
||||
const fields = HeroIni.section(doc("item"), weapon)?.fields || [];
|
||||
const used = new Set(normal.map((s) => s.key.toLowerCase()));
|
||||
const addMove = (label, keys) => {
|
||||
const steps = keys.map((key) => ({ key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) })).filter((x) => x.name);
|
||||
if (!steps.length) return;
|
||||
steps.forEach((x) => used.add(x.key.toLowerCase()));
|
||||
const refs = steps.flatMap((x) => x.refs);
|
||||
combos.push(makeAction(label, steps.map((x) => x.name).join(" → "), {
|
||||
label, animations: steps.map((x) => x.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs,
|
||||
description: steps.map((x) => `[${x.key}] ${x.name}`).concat(refs.map(attributeSummary)).join("\n"),
|
||||
}));
|
||||
};
|
||||
const commandIds = [...new Set(fields.map((f) => /^((?:jump_)?cmd_info)(\d+)$/.exec(f.key.toLowerCase())).filter(Boolean).map((m) => `${m[1]}${m[2]}`))];
|
||||
for (const id of commandIds) {
|
||||
const m = /^((?:jump_)?cmd_info)(\d+)$/.exec(id), base = `${m[1]}${m[2]}`;
|
||||
const code = value("item", weapon, base);
|
||||
const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" };
|
||||
const label = `${m[1].startsWith("jump") ? "Air " : ""}${[...code].map((c) => arrows[c] || c).join("")}`;
|
||||
const keys = fields.filter((f) => new RegExp(`^${base}_combo\\d+$`, "i").test(f.key) && ani(f.value)).map((f) => f.key);
|
||||
if (keys.length) addMove(label, keys);
|
||||
if (!keys.length && value("item", weapon, `${base}_skill_use`) === "1") {
|
||||
const slot = Number(value("item", weapon, `${base}_skill_slot`));
|
||||
const spec = skillSpec(`skill${slot + 1}`, label);
|
||||
combos.push(makeAction(label, spec.title || `Skill slot ${slot + 1}`, spec.action));
|
||||
}
|
||||
for (const suffix of ["d", "s"]) {
|
||||
const branches = fields.filter((f) => new RegExp(`^${base}_combo\\d+_${suffix}$`, "i").test(f.key) && ani(f.value)).map((f) => f.key);
|
||||
for (const branch of branches) addMove(`${label} + ${suffix.toUpperCase()}`, [...keys.slice(0, -1), branch]);
|
||||
}
|
||||
}
|
||||
const labels = { dash_attack: "Dash + D", assault_dash_attack: "Assault dash + D", jump_attack: "Air + D", extend_jump_attack: "Extended air + D", land_jump_attack: "Landing + D", counter_attack: "Counter", run_animation: "Run", dash_animation: "Dash", jumpping_animation: "Jump", jump_end_animation: "Land", defense_animation: "Defense" };
|
||||
const isSelfMove = (key) => !/(?:_wounded(?:_|$)|^wound|^shop_|^award_|^win_|^lose_|^special_motion|_camera$)/i.test(key);
|
||||
for (const f of fields) {
|
||||
const key = f.key.toLowerCase();
|
||||
if (used.has(key) || !ani(f.value) || !isSelfMove(key)) continue;
|
||||
const label = labels[key] || (/half.*attack/i.test(key) ? `${key.startsWith("dash") ? "Dash " : key.startsWith("jump") ? "Air " : ""}Half D · ${key}` : key.replace(/_/g, " "));
|
||||
addMove(label, [f.key]);
|
||||
}
|
||||
$("hero-combos").replaceChildren(...combos);
|
||||
const slots = [["SD", "skill1"], ["ASD", "skill2"], ["AD", "skill3"], ["AS", "skill4"]];
|
||||
$("hero-skills").replaceChildren(...slots.map(([label, section]) => {
|
||||
if (!doc("skill") || !HeroIni.section(doc("skill"), section)) return makeAction(label, "No skill", { label, animations: [], description: "No skill definition" });
|
||||
const spec = skillSpec(section, label);
|
||||
return makeAction(label, spec.title, spec.action, value("skill", section, "icon") || value("skill", section, "skill_emoticon"));
|
||||
}));
|
||||
}
|
||||
async function runAction(action, button) {
|
||||
const token = ++HERO.actionToken;
|
||||
const practice = HERO.practice;
|
||||
if (practice.active) { practice.busy = true; practice.lastMotion = ""; }
|
||||
MDL.clearHeroEffect();
|
||||
$("hero-action-label").textContent = action.label;
|
||||
$("hero-action-info").textContent = action.description || "No config details for this action.";
|
||||
for (const b of document.querySelectorAll(".hero-action")) b.classList.toggle("on", b === button);
|
||||
if (!action.animations.length) {
|
||||
MDL.stopHeroSequence();
|
||||
if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`));
|
||||
if (practice.active) { practiceHit(action); practice.busy = false; }
|
||||
$("hero-play-status").textContent = "Passive / no animation in config";
|
||||
return;
|
||||
}
|
||||
if (!HERO.previewReady) await buildPreview();
|
||||
if (!HERO.previewReady || token !== HERO.actionToken) return;
|
||||
$("hero-play-status").textContent = `${action.animations.length} animation step(s)`;
|
||||
try {
|
||||
if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`));
|
||||
let hitDone = false;
|
||||
const hit = () => { if (!hitDone && token === HERO.actionToken && practice.active) { hitDone = true; practiceHit(action); } };
|
||||
await MDL.playSequence(action.animations, {
|
||||
onStep: (name) => { if (token === HERO.actionToken) $("hero-play-status").textContent = `${action.label} · ${name}`; },
|
||||
onEvent: (event) => {
|
||||
if (token !== HERO.actionToken) return;
|
||||
if (/^fire_(?:s|e|sliding)$/i.test(event.type.text)) hit();
|
||||
if (event.type.text.toLowerCase() !== "auto_effect") return;
|
||||
const name = fx(event.sub.text);
|
||||
if (name) MDL.playHeroEffect(HERO.src, name).catch((err) => console.warn(`Animation effect: ${err.message}`));
|
||||
},
|
||||
onDone: () => { if (token === HERO.actionToken) { hit(); $("hero-play-status").textContent = `${action.label} complete`; if (practice.active) { practice.busy = false; practice.lastMotion = ""; updatePracticeMotion(); } else MDL.playNamed(value("item", "item1", "delay_animation")); } },
|
||||
});
|
||||
if (practice.active) setTimeout(hit, 400);
|
||||
} catch (err) { practice.busy = false; toast(err.message, true); $("hero-play-status").textContent = err.message; }
|
||||
}
|
||||
const practiceButtons = () => [...document.querySelectorAll("#hero-combos .hero-action")];
|
||||
const actionLabel = (button) => button?.querySelector("b")?.textContent || "";
|
||||
const actionButton = (label) => practiceButtons().find((button) => actionLabel(button) === label);
|
||||
const practiceVisible = () => HERO.practice.active && HERO.id && $("view-hero").classList.contains("on");
|
||||
function practiceHit(action) {
|
||||
const p = HERO.practice;
|
||||
if (!p.active || (!action.refs?.length && !action.effectTarget)) return;
|
||||
const dx = p.target[0] - p.pos[0], dz = p.target[2] - p.pos[2];
|
||||
const distance = Math.hypot(dx, dz);
|
||||
const facing = (Math.sin(p.yaw * Math.PI / 180) * dx + Math.cos(p.yaw * Math.PI / 180) * dz) / (distance || 1);
|
||||
if (distance > 155 || facing < 0.2) { $("hero-play-status").textContent = `${action.label} · miss`; return; }
|
||||
const rates = (action.refs || []).map((ref) => Number.parseFloat(value("attack", `attribute${ref}`, "damage_rate")) || 1);
|
||||
const damage = Math.max(5, Math.min(35, Math.round(rates.reduce((sum, rate) => sum + rate, 0) * 2)));
|
||||
p.hp = Math.max(0, p.hp - damage);
|
||||
$("hero-dummy-health").textContent = `Dummy ${p.hp} / 100 · −${damage}`;
|
||||
$("hero-play-status").textContent = `${action.label} · hit ${damage}`;
|
||||
if (action.effectTarget && action.effect) MDL.playHeroEffect(HERO.src, action.effect, { target: true }).catch((err) => console.warn(`Practice hit effect: ${err.message}`));
|
||||
if (p.hp === 0) setTimeout(() => { if (p.active && p.hp === 0) { p.hp = 100; $("hero-dummy-health").textContent = "Dummy 100 / 100"; } }, 1800);
|
||||
}
|
||||
function updatePracticeMotion() {
|
||||
const p = HERO.practice;
|
||||
if (!practiceVisible() || !HERO.previewReady || p.busy) return;
|
||||
const moving = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].some((key) => p.keys.has(key));
|
||||
const dashing = p.keys.has("Shift") || performance.now() < p.dashUntil;
|
||||
const key = !p.grounded ? "jumpping_animation" : p.keys.has("s") ? "defense_animation" : moving ? dashing ? "dash_animation" : "run_animation" : "delay_animation";
|
||||
const name = ani(value("item", "item1", key)) || ani(value("item", "item1", "delay_animation"));
|
||||
if (!name || name === p.lastMotion) return;
|
||||
p.lastMotion = name;
|
||||
MDL.playHeroMotion(name).catch((err) => console.warn(`Practice motion ${name}: ${err.message}`));
|
||||
}
|
||||
async function setPractice(on) {
|
||||
const p = HERO.practice;
|
||||
if (on && !HERO.previewReady) await buildPreview();
|
||||
if (on && !HERO.previewReady) return;
|
||||
p.active = !!on;
|
||||
p.keys.clear(); p.directions = []; p.combo = 0; p.busy = false; p.chord = false;
|
||||
p.pos = [0, 0, 0]; p.yaw = 0; p.vy = 0; p.grounded = true; p.dashUntil = 0; p.hp = 100; p.lastMotion = "";
|
||||
clearTimeout(p.attackTimer);
|
||||
$("hero-practice").classList.toggle("on", p.active);
|
||||
$("hero-practice").textContent = p.active ? "Exit training" : "Training mode";
|
||||
$("hero-practice-hud").hidden = !p.active;
|
||||
$("hero-practice-help").hidden = !p.active;
|
||||
$("hero-dummy-health").textContent = "Dummy 100 / 100";
|
||||
MDL.clearHeroEffect();
|
||||
MDL.stopHeroSequence();
|
||||
MDL.setHeroTransform(p.pos, p.yaw);
|
||||
MDL.setHeroPracticeTarget(p.active ? p.target : null);
|
||||
MDL.setPracticeFloor?.(p.active ? PRACTICE_ARENA : null);
|
||||
if (p.active) {
|
||||
$("hero-stage").focus({ preventScroll: true });
|
||||
if (MDL.view) { MDL.view.cam.target = [0, 75, -110]; MDL.view.cam.dist = Math.max(450, MDL.view.cam.dist); MDL.view.dirty = true; }
|
||||
$("hero-play-status").textContent = "Training ready · move toward the dummy";
|
||||
updatePracticeMotion();
|
||||
} else {
|
||||
MDL.view?.reset();
|
||||
$("hero-play-status").textContent = "Training stopped";
|
||||
}
|
||||
}
|
||||
$("hero-practice").onclick = () => setPractice(!HERO.practice.active);
|
||||
|
||||
function commandButton() {
|
||||
const p = HERO.practice, now = performance.now();
|
||||
const maxTime = Number.parseInt(value("item", "item1", "input_max_time"), 10) || 800;
|
||||
p.directions = p.directions.filter((x) => now - x.time <= maxTime);
|
||||
const input = p.directions.map((x) => x.code).join("");
|
||||
const fields = HeroIni.section(doc("item"), "item1")?.fields || [];
|
||||
const commands = fields.filter((f) => /^(?:jump_)?cmd_info\d+$/i.test(f.key) && /^\d+$/.test(f.value))
|
||||
.filter((f) => f.key.startsWith("jump_") === !p.grounded && input.endsWith(f.value))
|
||||
.sort((a, b) => b.value.length - a.value.length);
|
||||
const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" };
|
||||
for (const cmd of commands) {
|
||||
const label = `${cmd.key.startsWith("jump_") ? "Air " : ""}${[...cmd.value].map((c) => arrows[c]).join("")}`;
|
||||
const button = actionButton(label);
|
||||
if (button) { p.directions = []; return button; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function practiceAttack(held = 0) {
|
||||
const p = HERO.practice;
|
||||
if (!practiceVisible() || !HERO.previewReady) return;
|
||||
let button = null;
|
||||
if (held >= Math.max(250, Number.parseInt(value("item", "item1", "half_charge_time"), 10) || Number.parseInt(value("item", "item1", "half_charging_time"), 10) || 450))
|
||||
button = practiceButtons().find((b) => /^Half D\b/.test(actionLabel(b)));
|
||||
if (!button) button = commandButton();
|
||||
if (!button && !p.grounded) button = practiceButtons().find((b) => /^(Air \+ D|jump attack\d+|Extended air \+ D)/i.test(actionLabel(b)));
|
||||
if (!button && (p.keys.has("Shift") || performance.now() < p.dashUntil)) button = actionButton("Assault dash + D") || actionButton("Dash + D");
|
||||
if (button) { p.combo = 0; runAction(button.heroAction, button); return; }
|
||||
const max = Math.min(12, Number.parseInt(value("item", "item1", "max_combo"), 10) || 1);
|
||||
p.combo = performance.now() - p.comboAt < 850 ? p.combo % max + 1 : 1;
|
||||
p.comboAt = performance.now();
|
||||
button = actionButton(Array(p.combo).fill("D").join(" ")) || actionButton("D");
|
||||
if (!button) return;
|
||||
const key = `normal_attack${String(p.combo).padStart(2, "0")}`;
|
||||
const name = ani(value("item", "item1", key));
|
||||
const refs = attackRefs("item", "item1", key);
|
||||
const action = { label: actionLabel(button), animations: name ? [name] : button.heroAction.animations,
|
||||
effect: actionEffect("item", "item1", refs), effectTarget: true, refs,
|
||||
description: `[${key}] ${name}\n${refs.map(attributeSummary).join("\n")}` };
|
||||
runAction(action, button);
|
||||
}
|
||||
function practiceSkill(slot) {
|
||||
const button = [...document.querySelectorAll("#hero-skills .hero-action")][slot];
|
||||
if (button) { HERO.practice.combo = 0; runAction(button.heroAction, button); }
|
||||
}
|
||||
const practiceKey = (event) => event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!practiceVisible() || event.target.matches?.("input, textarea, select, [contenteditable]") || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const p = HERO.practice, key = practiceKey(event), now = performance.now();
|
||||
if (key === "Escape") { event.preventDefault(); setPractice(false); return; }
|
||||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d", "1", "2", "3", "4"].includes(key)) return;
|
||||
event.preventDefault();
|
||||
const wasDown = p.keys.has(key);
|
||||
p.keys.add(key);
|
||||
if (/^Arrow/.test(key)) {
|
||||
if (!wasDown) {
|
||||
const codes = { ArrowUp: "1", ArrowRight: "2", ArrowDown: "3", ArrowLeft: "4" };
|
||||
p.directions.push({ code: codes[key], time: now });
|
||||
p.directions = p.directions.slice(-12);
|
||||
if (now - (p.lastTap[key] || 0) < 280) p.dashUntil = now + 500;
|
||||
p.lastTap[key] = now;
|
||||
}
|
||||
updatePracticeMotion();
|
||||
return;
|
||||
}
|
||||
if (key === "Shift") { updatePracticeMotion(); return; }
|
||||
if (wasDown) return;
|
||||
if (/^[1-4]$/.test(key)) { practiceSkill(Number(key) - 1); return; }
|
||||
const a = p.keys.has("a"), s = p.keys.has("s"), d = p.keys.has("d");
|
||||
const slot = a && s && d ? 1 : s && d ? 0 : a && d ? 2 : a && s ? 3 : -1;
|
||||
if (slot >= 0 && !p.chord) {
|
||||
p.chord = true; p.attackPending = false; clearTimeout(p.attackTimer);
|
||||
practiceSkill(slot);
|
||||
return;
|
||||
}
|
||||
if (key === "a" && p.grounded) {
|
||||
p.grounded = false;
|
||||
p.vy = 275 * (Number.parseFloat(value("item", "item1", "jump_power_rate")) || 1);
|
||||
p.lastMotion = "";
|
||||
updatePracticeMotion();
|
||||
} else if (key === "s") updatePracticeMotion();
|
||||
else if (key === "d") {
|
||||
p.attackDownAt = now; p.attackPending = true;
|
||||
const hasHalf = practiceButtons().some((button) => /^Half D\b/.test(actionLabel(button)));
|
||||
if (!hasHalf) p.attackTimer = setTimeout(() => {
|
||||
if (p.attackPending && p.keys.has("d") && !p.chord) { p.attackPending = false; practiceAttack(); }
|
||||
}, 75);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keyup", (event) => {
|
||||
if (!HERO.practice.active) return;
|
||||
const p = HERO.practice, key = practiceKey(event);
|
||||
if (!p.keys.has(key)) return;
|
||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d"].includes(key)) event.preventDefault();
|
||||
p.keys.delete(key);
|
||||
if (key === "d") {
|
||||
clearTimeout(p.attackTimer);
|
||||
if (p.attackPending && !p.chord) practiceAttack(performance.now() - p.attackDownAt);
|
||||
p.attackPending = false;
|
||||
}
|
||||
if (!["a", "s", "d"].some((k) => p.keys.has(k))) p.chord = false;
|
||||
if (/^Arrow/.test(key) || key === "Shift" || key === "s") updatePracticeMotion();
|
||||
});
|
||||
window.addEventListener("blur", () => { if (HERO.practice.active) { HERO.practice.keys.clear(); HERO.practice.attackPending = false; updatePracticeMotion(); } });
|
||||
let practiceLastFrame = 0;
|
||||
const PRACTICE_ARENA = 450; // half size of the training floor, game units
|
||||
function practiceTick(now) {
|
||||
const dt = Math.min(0.05, Math.max(0, (now - (practiceLastFrame || now)) / 1000));
|
||||
practiceLastFrame = now;
|
||||
const p = HERO.practice;
|
||||
if (practiceVisible() && HERO.previewReady) {
|
||||
const dx = Number(p.keys.has("ArrowRight")) - Number(p.keys.has("ArrowLeft"));
|
||||
const dz = Number(p.keys.has("ArrowUp")) - Number(p.keys.has("ArrowDown"));
|
||||
const length = Math.hypot(dx, dz) || 1;
|
||||
const moving = !!(dx || dz);
|
||||
const dashing = p.keys.has("Shift") || now < p.dashUntil;
|
||||
const dashRate = Number.parseFloat(value("item", "item1", "dash_run_max_speed_rate")) || 1;
|
||||
const speed = (dashing ? 240 * dashRate : 125) * (p.busy ? 0.35 : 1);
|
||||
if (moving) {
|
||||
const edge = PRACTICE_ARENA - 20; // keep the whole body on the floor grid
|
||||
p.pos[0] = Math.max(-edge, Math.min(edge, p.pos[0] + dx / length * speed * dt));
|
||||
p.pos[2] = Math.max(-edge, Math.min(edge, p.pos[2] + dz / length * speed * dt));
|
||||
p.yaw = Math.atan2(dx, dz) * 180 / Math.PI;
|
||||
}
|
||||
if (!p.grounded) {
|
||||
p.vy -= 780 * dt;
|
||||
p.pos[1] += p.vy * dt;
|
||||
if (p.pos[1] <= 0) { p.pos[1] = 0; p.vy = 0; p.grounded = true; p.lastMotion = ""; updatePracticeMotion(); }
|
||||
}
|
||||
if (moving || !p.grounded) MDL.setHeroTransform(p.pos, p.yaw);
|
||||
// Camera follows the hero (looking a little toward the dummy) and backs off when the two are far
|
||||
// apart, so the hero never walks out of the frame. It never zooms in: a manual zoom-out stays.
|
||||
// The view's z axis is the game's z flipped (see the start target [0, 75, -110]).
|
||||
if (MDL.view) {
|
||||
const cam = MDL.view.cam, k = Math.min(1, 4 * dt);
|
||||
const fx = p.pos[0] + (p.target[0] - p.pos[0]) * 0.35, fz = p.pos[2] + (p.target[2] - p.pos[2]) * 0.35;
|
||||
const need = Math.min(1000, 380 + Math.hypot(p.target[0] - p.pos[0], p.target[2] - p.pos[2]) * 0.9);
|
||||
const before = cam.target[0] + cam.target[2] + cam.dist;
|
||||
cam.target[0] += (fx - cam.target[0]) * k;
|
||||
cam.target[2] += (-fz - cam.target[2]) * k;
|
||||
if (cam.dist < need) cam.dist += (need - cam.dist) * k;
|
||||
if (Math.abs(cam.target[0] + cam.target[2] + cam.dist - before) > 0.01) MDL.view.dirty = true;
|
||||
}
|
||||
updatePracticeMotion();
|
||||
}
|
||||
requestAnimationFrame(practiceTick);
|
||||
}
|
||||
requestAnimationFrame(practiceTick);
|
||||
$("hero-idle").onclick = async () => {
|
||||
if (HERO.practice.active) await setPractice(false);
|
||||
HERO.actionToken++;
|
||||
MDL.stopHeroSequence();
|
||||
const name = value("item", "item1", "delay_animation");
|
||||
if (name) await MDL.playNamed(name);
|
||||
$("hero-action-label").textContent = "Idle";
|
||||
$("hero-play-status").textContent = name || "Idle pose";
|
||||
};
|
||||
$("hero-stop").onclick = () => { if (HERO.practice.active) setPractice(false); HERO.actionToken++; MDL.stopHeroSequence(); $("hero-play-status").textContent = "Stopped"; };
|
||||
$("hero-export").onclick = async () => {
|
||||
const files = dirty().map((kind) => ({ kind, text: HERO.docs[kind].text }));
|
||||
if (!files.length) return;
|
||||
const folder = await call("dialog:folder", "Choose a folder for the exported hero configs");
|
||||
if (!folder) return;
|
||||
try {
|
||||
const out = await call("hero:export", folder, HERO.id, files);
|
||||
for (const f of files) HERO.docs[f.kind].saved = f.text;
|
||||
updateStatus();
|
||||
$("hero-export-status").textContent = `Exported to ${out.folder}`;
|
||||
toast(`Exported ${out.files.length} config file(s).`);
|
||||
} catch (err) { toast(err.message, true); }
|
||||
};
|
||||
document.querySelector('[data-view="hero"]').addEventListener("click", async () => {
|
||||
if (HERO.src) {
|
||||
if (HERO.id && MDL.scene?.model?.name === "Hero preview") MDL.embedHero(true);
|
||||
else if (HERO.id) buildPreview();
|
||||
return;
|
||||
}
|
||||
const src = profileSource();
|
||||
if (src) try { await setSource(src); } catch (err) { toast(err.message, true); }
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,866 @@
|
||||
"use strict";
|
||||
|
||||
// UI Image Set editor (uiimageset.xml): browse imagesets, see every image as a box on its texture,
|
||||
// move/resize boxes, draw new ones, add/duplicate/delete images and imagesets, then save the XML
|
||||
// into the pack or as a file. Lines that weren't edited are written back exactly as they were.
|
||||
|
||||
const IMS = {
|
||||
doc: null, // { eol, head: [lines], tail: [lines], sets: [set] }
|
||||
source: null, // { kind: "pack", sessionId, entry, file } | { kind: "file", file }
|
||||
texSource: null, // where textures come from (see setTexSource)
|
||||
dirty: false,
|
||||
set: -1,
|
||||
img: -1,
|
||||
undo: [],
|
||||
redo: [],
|
||||
zoom: 1,
|
||||
panX: 20,
|
||||
panY: 20,
|
||||
showAll: true,
|
||||
draw: false,
|
||||
tex: new Map(), // file → Promise<{ bitmap, w, h, source } | { error }>
|
||||
};
|
||||
|
||||
// ---------- XML model ----------
|
||||
const parseAttrs = (s) => [...s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)].map((m) => [m[1], m[2]]);
|
||||
const attr = (o, k) => o.attrs.find((a) => a[0] === k)?.[1] ?? "";
|
||||
const num = (o, k) => Number(attr(o, k)) || 0;
|
||||
function setAttr(o, k, v) {
|
||||
const a = o.attrs.find((x) => x[0] === k);
|
||||
if (a) a[1] = String(v);
|
||||
else o.attrs.push([k, String(v)]);
|
||||
o.raw = null; // this line is written again
|
||||
}
|
||||
|
||||
// Lines keep their own line break: the file mixes CRLF with bare CR in places, and untouched lines
|
||||
// must come back byte for byte. `raw` = line text + its break; edited lines get the file's usual one.
|
||||
function splitLines(text) {
|
||||
const out = [];
|
||||
const re = /([^\r\n]*)(\r\n|\r|\n|$)/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) && m.index < text.length) {
|
||||
out.push({ text: m[1], raw: m[0] });
|
||||
if (!m[2]) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseDoc(text) {
|
||||
const crlf = (text.match(/\r\n/g) || []).length;
|
||||
const eol = crlf || !text.includes("\n") ? "\r\n" : "\n";
|
||||
const doc = { eol, head: [], tail: [], sets: [] };
|
||||
let cur = null;
|
||||
let pending = []; // lines between two imagesets (blank lines): they stay after the previous one
|
||||
for (const { text: line, raw } of splitLines(text)) {
|
||||
let m;
|
||||
if ((m = /^\s*<Imageset\b(.*?)(\/?)>\s*$/.exec(line))) {
|
||||
if (doc.sets.length) doc.sets[doc.sets.length - 1].after = pending;
|
||||
pending = [];
|
||||
const set = { attrs: parseAttrs(m[1]), raw, images: [], rawClose: null, selfClosing: m[2] === "/", after: [] };
|
||||
doc.sets.push(set);
|
||||
cur = set.selfClosing ? null : set;
|
||||
} else if (cur && (m = /^\s*<Image\b(.*?)\/>\s*$/.exec(line))) {
|
||||
cur.images.push({ attrs: parseAttrs(m[1]), raw });
|
||||
} else if (cur && /^\s*<\/Imageset>\s*$/.test(line)) {
|
||||
cur.rawClose = raw;
|
||||
cur = null;
|
||||
} else if (cur) {
|
||||
cur.images.push({ keep: true, raw }); // blank line etc. inside a set, kept as is
|
||||
} else if (!doc.sets.length) doc.head.push(raw);
|
||||
else pending.push(raw);
|
||||
}
|
||||
doc.tail = pending; // whatever follows the last imageset (</ImagesetLayout>)
|
||||
if (!doc.head.length) doc.head = [`<?xml version="1.0" encoding="euc-kr" ?>${eol}`, `<ImagesetLayout>${eol}`];
|
||||
if (!doc.tail.some((l) => /<\/ImagesetLayout>/.test(l))) doc.tail.unshift(`</ImagesetLayout>${eol}`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
const attrText = (attrs) => attrs.map(([k, v]) => `${k}="${v}"`).join(" ");
|
||||
function serialize(doc) {
|
||||
const e = doc.eol;
|
||||
const out = [...doc.head];
|
||||
// a line before a generated one must end with a break
|
||||
const push = (s) => {
|
||||
const last = out.length - 1;
|
||||
if (last >= 0 && !/[\r\n]$/.test(out[last])) out[last] += e;
|
||||
out.push(s);
|
||||
};
|
||||
for (const s of doc.sets) {
|
||||
const selfClose = s.selfClosing && !s.images.length;
|
||||
push(s.raw ?? ` <Imageset ${attrText(s.attrs)}${selfClose ? " /" : ""}>${e}`);
|
||||
if (!selfClose) {
|
||||
for (const i of s.images) push(i.raw ?? ` <Image ${attrText(i.attrs)} />${e}`);
|
||||
push(s.rawClose ?? ` </Imageset>${e}`);
|
||||
}
|
||||
for (const a of s.after || []) push(a);
|
||||
}
|
||||
for (const t of doc.tail) push(t);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
const images = (set) => set.images.filter((i) => !i.keep);
|
||||
const curSet = () => IMS.doc?.sets[IMS.set] || null;
|
||||
const curImg = () => (curSet() ? images(curSet())[IMS.img] || null : null);
|
||||
const rectOf = (i) => ({ x: num(i, "X"), y: num(i, "Y"), w: num(i, "Width"), h: num(i, "Height") });
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
const validName = (s) => /^[\x21-\x7e]+$/.test(s) && !/["<>&]/.test(s);
|
||||
|
||||
// ---------- undo ----------
|
||||
function snapshot() {
|
||||
IMS.undo.push({ sets: IMS.doc.sets.map((s) => s), setCopy: IMS.set >= 0 ? { i: IMS.set, data: clone(curSet()) } : null, set: IMS.set, img: IMS.img });
|
||||
if (IMS.undo.length > 200) IMS.undo.shift();
|
||||
IMS.redo = [];
|
||||
markDirty();
|
||||
}
|
||||
function restore(from, to) {
|
||||
const s = from.pop();
|
||||
if (!s) return;
|
||||
to.push({ sets: IMS.doc.sets.map((x) => x), setCopy: s.setCopy ? { i: s.setCopy.i, data: clone(IMS.doc.sets[s.setCopy.i] || s.setCopy.data) } : null, set: IMS.set, img: IMS.img });
|
||||
IMS.doc.sets = s.sets.slice();
|
||||
if (s.setCopy) IMS.doc.sets[s.setCopy.i] = clone(s.setCopy.data);
|
||||
IMS.set = s.set;
|
||||
IMS.img = s.img;
|
||||
markDirty();
|
||||
renderAll();
|
||||
}
|
||||
const undo = () => restore(IMS.undo, IMS.redo);
|
||||
const redo = () => restore(IMS.redo, IMS.undo);
|
||||
|
||||
function markDirty() {
|
||||
IMS.dirty = true;
|
||||
$("ims-dirty").hidden = false;
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
async function imsSessions() {
|
||||
const list = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||||
const sel = $("ims-session");
|
||||
sel.replaceChildren(...(list.length ? list.map((s) => el("option", { value: s.id, text: s.name, title: s.file })) : [el("option", { value: "", text: "Open rs.iop in IOP Manager first" })]));
|
||||
const rs = list.find((s) => s.name.toLowerCase() === "rs.iop");
|
||||
if (rs) sel.value = rs.id;
|
||||
sel.disabled = !list.length;
|
||||
$("ims-load").disabled = !list.length;
|
||||
return list;
|
||||
}
|
||||
|
||||
async function confirmDiscard() {
|
||||
if (!IMS.dirty) return true;
|
||||
return confirmBox("Unsaved changes", "The current imageset file has changes that aren't saved. Discard them?", "Discard", "danger");
|
||||
}
|
||||
|
||||
// ---------- texture source ----------
|
||||
// session = the packs next to a file open in IOP Manager; packs = a folder of .iop files;
|
||||
// dir = a folder of extracted files (e.g. map.iop extracted)
|
||||
async function setTexSource(src) {
|
||||
try {
|
||||
const d = await call("ims:describeSource", src);
|
||||
IMS.texSource = { ...src, label: d.label };
|
||||
} catch (err) {
|
||||
return toast(err.message, true);
|
||||
}
|
||||
$("ims-texsrc-label").textContent = IMS.texSource.kind === "dir" ? "Extracted folder" : IMS.texSource.kind === "packs" ? "Pack folder" : "Packs next to " + IMS.texSource.name;
|
||||
$("ims-texsrc").title = `Textures: ${IMS.texSource.label}`;
|
||||
IMS.tex.clear();
|
||||
if (curSet()) {
|
||||
await loadTexture();
|
||||
drawCanvas();
|
||||
drawCrop();
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks where the textures are; resolves true when a source was set. */
|
||||
async function chooseTexSource() {
|
||||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||||
const pick = (value) => (choice = value);
|
||||
let choice = null;
|
||||
const options = [
|
||||
...profileSourceOption(),
|
||||
...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })),
|
||||
{ value: "packs", title: "A folder with .iop packs…", sub: "map.iop, map2.iop, sakura.iop… — passwords found from the profiles" },
|
||||
{ value: "dir", title: "A folder of extracted files…", sub: "e.g. map.iop extracted: resource/texture/*.dds" },
|
||||
];
|
||||
const list = el(
|
||||
"div",
|
||||
{ class: "choice-list" },
|
||||
options.map((o, i) =>
|
||||
el("label", { class: "choice" }, [
|
||||
el("input", { type: "radio", name: "texsrc", checked: i === 0, onchange: () => pick(o.value) }),
|
||||
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
choice = options[0].value;
|
||||
const ok = await modal("Where are the textures?", ["The imageset XML only names its textures (ui_icon_event1.dds). Pick where to read them from so the boxes match the right pictures.", list], [
|
||||
{ label: "Cancel", value: false },
|
||||
{ label: "Use this", kind: "primary", value: true },
|
||||
]);
|
||||
if (!ok) return false;
|
||||
let src = choice;
|
||||
if (choice === "packs" || choice === "dir") {
|
||||
const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files");
|
||||
if (!folder) return false;
|
||||
src = { kind: choice, folder };
|
||||
}
|
||||
await setTexSource(src);
|
||||
return true;
|
||||
}
|
||||
$("ims-texsrc").onclick = () => chooseTexSource();
|
||||
|
||||
async function loadDoc(result) {
|
||||
IMS.doc = parseDoc(result.text);
|
||||
IMS.source = result.source;
|
||||
IMS.dirty = false;
|
||||
IMS.undo = [];
|
||||
IMS.redo = [];
|
||||
IMS.set = -1;
|
||||
IMS.img = -1;
|
||||
IMS.tex.clear();
|
||||
$("ims-dirty").hidden = true;
|
||||
$("ims-title").textContent = result.source.kind === "pack" ? `${result.source.file.split(/[\\/]/).pop()} › ${result.source.entry}` : result.source.file;
|
||||
$("ims-empty").hidden = true;
|
||||
renderAll();
|
||||
if (IMS.doc.sets.length) selectSet(0);
|
||||
const n = IMS.doc.sets.reduce((a, s) => a + images(s).length, 0);
|
||||
toast(`${IMS.doc.sets.length.toLocaleString()} imagesets, ${n.toLocaleString()} images.`);
|
||||
}
|
||||
|
||||
$("ims-load").onclick = async () => {
|
||||
if (!(await confirmDiscard())) return;
|
||||
const id = Number($("ims-session").value);
|
||||
try {
|
||||
const entry = $("ims-entry").value.trim() || "xml/uiimageset.xml";
|
||||
const r = await call("ims:loadPack", id, entry);
|
||||
// Textures default to the packs next to this one (can be changed with the Textures button)
|
||||
const s = (await call("iop:sessions")).find((x) => x.id === id);
|
||||
await setTexSource({ kind: "session", sessionId: id, name: s?.name || "pack" });
|
||||
await loadDoc(r);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
};
|
||||
$("ims-open-file").onclick = async () => {
|
||||
if (!(await confirmDiscard())) return;
|
||||
const file = await call("dialog:openXml");
|
||||
if (!file) return;
|
||||
try {
|
||||
const r = await call("ims:loadFile", file);
|
||||
// A loose XML says nothing about where its textures are: ask
|
||||
if (!(await chooseTexSource())) toast("No texture source: the boxes show without pictures. Use the Textures button to pick one.", true);
|
||||
await loadDoc(r);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- saving ----------
|
||||
async function saveToPack() {
|
||||
if (IMS.source?.kind !== "pack") return toast("This XML came from a file. Use “Save as file…”, or load it from a pack.", true);
|
||||
try {
|
||||
await call("ims:savePack", IMS.source.sessionId, IMS.source.entry, serialize(IMS.doc));
|
||||
IMS.dirty = false;
|
||||
$("ims-dirty").hidden = true;
|
||||
toast(`Saved into ${IMS.source.file.split(/[\\/]/).pop()} (${IMS.source.entry}).`);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
async function saveToFile() {
|
||||
const name = (IMS.source?.entry || IMS.source?.file || "uiimageset.xml").split(/[\\/]/).pop();
|
||||
const file = await call("dialog:saveXml", name);
|
||||
if (!file) return;
|
||||
try {
|
||||
await call("ims:saveFile", file, serialize(IMS.doc));
|
||||
if (IMS.source?.kind === "file") {
|
||||
IMS.source.file = file;
|
||||
IMS.dirty = false;
|
||||
$("ims-dirty").hidden = true;
|
||||
}
|
||||
toast(`Saved ${file}.`);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
$("ims-save").onclick = (e) =>
|
||||
showMenu(
|
||||
[
|
||||
{ label: "Save into the pack", icon: "box", disabled: IMS.source?.kind !== "pack", hint: "Ctrl+S", run: saveToPack },
|
||||
{ label: "Save as file…", icon: "download", disabled: !IMS.doc, run: saveToFile },
|
||||
"-",
|
||||
{ label: "Export this image as PNG…", icon: "external", disabled: !curImg(), run: exportPng },
|
||||
{
|
||||
label: "Reload textures",
|
||||
icon: "refresh",
|
||||
disabled: !IMS.texSource,
|
||||
run: async () => {
|
||||
await call("ims:reloadTextures", IMS.texSource);
|
||||
IMS.tex.clear();
|
||||
await loadTexture();
|
||||
drawCanvas();
|
||||
drawCrop();
|
||||
},
|
||||
},
|
||||
],
|
||||
{ anchor: e.currentTarget },
|
||||
);
|
||||
$("ims-undo").onclick = undo;
|
||||
$("ims-redo").onclick = redo;
|
||||
|
||||
// ---------- imageset list ----------
|
||||
function renderSets() {
|
||||
const q = $("ims-search").value.trim().toLowerCase();
|
||||
const list = $("ims-sets");
|
||||
if (!IMS.doc) return list.replaceChildren();
|
||||
const frag = document.createDocumentFragment();
|
||||
IMS.doc.sets.forEach((s, i) => {
|
||||
const name = attr(s, "Name");
|
||||
const file = attr(s, "File");
|
||||
if (q && !name.toLowerCase().includes(q) && !file.toLowerCase().includes(q)) return;
|
||||
frag.append(
|
||||
el("div", { class: `ims-set${i === IMS.set ? " on" : ""}`, "data-i": i, onclick: () => selectSet(i) }, [
|
||||
el("b", { text: name || "(no name)" }),
|
||||
el("span", { text: `${file} · ${images(s).length}` }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
list.replaceChildren(frag);
|
||||
$("ims-set-count").textContent = `${IMS.doc.sets.length.toLocaleString()}`;
|
||||
}
|
||||
$("ims-search").addEventListener("input", renderSets);
|
||||
|
||||
async function selectSet(i, { keepView = false } = {}) {
|
||||
IMS.set = i;
|
||||
IMS.img = images(curSet()).length ? 0 : -1;
|
||||
for (const n of $("ims-sets").children) n.classList.toggle("on", Number(n.dataset.i) === i);
|
||||
renderSide();
|
||||
await loadTexture();
|
||||
if (!keepView) fitView();
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
function selectImg(i) {
|
||||
IMS.img = i;
|
||||
renderImages();
|
||||
renderImgForm();
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
// ---------- textures ----------
|
||||
function textureFor(file) {
|
||||
const key = file.toLowerCase();
|
||||
if (!IMS.tex.has(key)) {
|
||||
IMS.tex.set(
|
||||
key,
|
||||
(async () => {
|
||||
if (!IMS.texSource) return { error: "No texture source — use the Textures button" };
|
||||
const t = await call("ims:texture", IMS.texSource, file);
|
||||
if (!t) return { error: `${file} isn't in ${IMS.texSource.label}` };
|
||||
try {
|
||||
const bitmap = await TexDecode.decode(t.bytes, t.ext);
|
||||
return { bitmap, w: bitmap.width, h: bitmap.height, source: t.source };
|
||||
} catch (err) {
|
||||
return { error: `${file}: ${err.message}` };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return IMS.tex.get(key);
|
||||
}
|
||||
|
||||
async function loadTexture() {
|
||||
const s = curSet();
|
||||
IMS.cur = null;
|
||||
$("ims-texinfo").textContent = "Loading texture…";
|
||||
if (!s) return;
|
||||
const t = await textureFor(attr(s, "File"));
|
||||
if (curSet() !== s) return;
|
||||
IMS.cur = t.error ? null : t;
|
||||
$("ims-texinfo").textContent = t.error ? t.error : `${t.w} × ${t.h} · ${t.source}`;
|
||||
$("ims-texinfo").classList.toggle("bad", !!t.error);
|
||||
}
|
||||
|
||||
// ---------- side panel ----------
|
||||
function field(label, value, onCommit, { type = "text", width } = {}) {
|
||||
const input = el("input", { type, value, spellcheck: "false" });
|
||||
if (width) input.style.width = width;
|
||||
input.addEventListener("change", () => onCommit(input.value.trim(), input));
|
||||
input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur());
|
||||
return el("label", { class: "fld" }, [el("span", { text: label }), input]);
|
||||
}
|
||||
|
||||
function renderSide() {
|
||||
const s = curSet();
|
||||
$("ims-side").hidden = !s;
|
||||
if (!s) return;
|
||||
$("ims-setform").replaceChildren(
|
||||
field("Name", attr(s, "Name"), (v, inp) => {
|
||||
if (!validName(v)) return ((inp.value = attr(s, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true));
|
||||
snapshot();
|
||||
setAttr(s, "Name", v);
|
||||
renderSets();
|
||||
}),
|
||||
field("Texture", attr(s, "File"), async (v, inp) => {
|
||||
if (!validName(v)) return ((inp.value = attr(s, "File")), toast("Use a plain file name, e.g. ui_icon_event1.dds.", true));
|
||||
snapshot();
|
||||
setAttr(s, "File", v);
|
||||
renderSets();
|
||||
await loadTexture();
|
||||
drawCanvas();
|
||||
}),
|
||||
...s.attrs.filter(([k]) => k !== "Name" && k !== "File").map(([k, v]) =>
|
||||
field(k, v, (nv) => {
|
||||
snapshot();
|
||||
setAttr(s, k, nv);
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderImages();
|
||||
renderImgForm();
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const s = curSet();
|
||||
if (!s) return;
|
||||
const q = $("ims-img-search").value.trim().toLowerCase();
|
||||
const frag = document.createDocumentFragment();
|
||||
images(s).forEach((im, i) => {
|
||||
const name = attr(im, "Name");
|
||||
if (q && !name.toLowerCase().includes(q)) return;
|
||||
const r = rectOf(im);
|
||||
frag.append(el("div", { class: `ims-img${i === IMS.img ? " on" : ""}`, onclick: () => selectImg(i) }, [el("span", { text: name }), el("em", { text: `${r.w}×${r.h}` })]));
|
||||
});
|
||||
$("ims-images").replaceChildren(frag);
|
||||
$("ims-img-count").textContent = images(s).length.toLocaleString();
|
||||
$("ims-images").querySelector(".on")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
$("ims-img-search").addEventListener("input", renderImages);
|
||||
|
||||
function renderImgForm() {
|
||||
const im = curImg();
|
||||
$("ims-imgbox").hidden = !im;
|
||||
if (!im) return;
|
||||
const intField = (k) =>
|
||||
field(k, attr(im, k), (v, inp) => {
|
||||
if (v !== "" && !/^-?\d+$/.test(v)) return ((inp.value = attr(im, k)), toast("Whole numbers only.", true));
|
||||
snapshot();
|
||||
if (v === "" && k.startsWith("Offset")) {
|
||||
im.attrs = im.attrs.filter((a) => a[0] !== k);
|
||||
im.raw = null;
|
||||
} else setAttr(im, k, v || "0");
|
||||
renderImages();
|
||||
drawCanvas();
|
||||
drawCrop();
|
||||
}, { type: "text", width: "100%" });
|
||||
$("ims-imgform").replaceChildren(
|
||||
field("Name", attr(im, "Name"), (v, inp) => {
|
||||
if (!validName(v)) return ((inp.value = attr(im, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true));
|
||||
if (images(curSet()).some((x) => x !== im && attr(x, "Name") === v)) return ((inp.value = attr(im, "Name")), toast("Another image in this imageset has that name.", true));
|
||||
snapshot();
|
||||
setAttr(im, "Name", v);
|
||||
renderImages();
|
||||
}),
|
||||
el("div", { class: "fld-grid" }, ["X", "Y", "Width", "Height", "OffsetX", "OffsetY"].map(intField)),
|
||||
);
|
||||
drawCrop();
|
||||
}
|
||||
|
||||
/** The selected image at its real size (2× when small). */
|
||||
function drawCrop() {
|
||||
const im = curImg();
|
||||
const c = $("ims-crop");
|
||||
const ctx = c.getContext("2d");
|
||||
if (!im || !IMS.cur) {
|
||||
c.width = c.height = 0;
|
||||
return;
|
||||
}
|
||||
const r = rectOf(im);
|
||||
const scale = Math.max(1, Math.min(4, Math.floor(160 / Math.max(r.w, r.h, 1))));
|
||||
c.width = Math.max(1, r.w * scale);
|
||||
c.height = Math.max(1, r.h * scale);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w * scale, r.h * scale);
|
||||
}
|
||||
|
||||
async function exportPng() {
|
||||
const im = curImg();
|
||||
if (!im || !IMS.cur) return;
|
||||
const r = rectOf(im);
|
||||
const c = document.createElement("canvas");
|
||||
c.width = r.w;
|
||||
c.height = r.h;
|
||||
c.getContext("2d").drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h);
|
||||
const file = await call("dialog:savePng", `${attr(im, "Name")}.png`);
|
||||
if (!file) return;
|
||||
await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]);
|
||||
toast(`Saved ${file}.`);
|
||||
}
|
||||
|
||||
// ---------- add / duplicate / delete ----------
|
||||
function uniqueName(set, base) {
|
||||
const names = new Set(images(set).map((i) => attr(i, "Name")));
|
||||
if (!names.has(base)) return base;
|
||||
for (let n = 2; ; n++) if (!names.has(`${base}_${n}`)) return `${base}_${n}`;
|
||||
}
|
||||
|
||||
function addImage(rect) {
|
||||
const s = curSet();
|
||||
if (!s) return;
|
||||
snapshot();
|
||||
const r = rect || { x: 0, y: 0, w: 32, h: 32 };
|
||||
const im = { attrs: [["Name", uniqueName(s, "new_image")], ["X", r.x], ["Y", r.y], ["Width", r.w], ["Height", r.h]].map(([k, v]) => [k, String(v)]), raw: null };
|
||||
s.images.push(im);
|
||||
s.selfClosing = false;
|
||||
if (!s.rawClose) s.raw = null;
|
||||
IMS.img = images(s).length - 1;
|
||||
renderSets();
|
||||
renderImages();
|
||||
renderImgForm();
|
||||
drawCanvas();
|
||||
}
|
||||
$("ims-add-img").onclick = () => addImage();
|
||||
$("ims-dup-img").onclick = () => {
|
||||
const im = curImg();
|
||||
if (!im) return;
|
||||
const r = rectOf(im);
|
||||
addImage({ x: r.x + 4, y: r.y + 4, w: r.w, h: r.h });
|
||||
const copy = curImg();
|
||||
for (const [k, v] of im.attrs) if (!["Name", "X", "Y"].includes(k)) setAttr(copy, k, v);
|
||||
setAttr(copy, "Name", uniqueName(curSet(), attr(im, "Name") + "_copy"));
|
||||
renderImages();
|
||||
renderImgForm();
|
||||
};
|
||||
function deleteImage() {
|
||||
const s = curSet();
|
||||
const im = curImg();
|
||||
if (!im) return;
|
||||
snapshot();
|
||||
s.images.splice(s.images.indexOf(im), 1);
|
||||
IMS.img = Math.min(IMS.img, images(s).length - 1);
|
||||
renderSets();
|
||||
renderImages();
|
||||
renderImgForm();
|
||||
drawCanvas();
|
||||
}
|
||||
$("ims-del-img").onclick = deleteImage;
|
||||
|
||||
$("ims-add-set").onclick = async () => {
|
||||
if (!IMS.doc) return;
|
||||
const name = await prompt("New imageset", "Imageset name (letters, digits, _)", "NewImageset");
|
||||
if (!name) return;
|
||||
if (!validName(name)) return toast("Use letters, digits and _ (no spaces or quotes).", true);
|
||||
if (IMS.doc.sets.some((s) => attr(s, "Name") === name)) return toast("An imageset with that name already exists.", true);
|
||||
const file = await prompt("Texture", "Texture file name (in resource/texture of the packs)", curSet() ? attr(curSet(), "File") : "ui_new.dds");
|
||||
if (!file) return;
|
||||
snapshot();
|
||||
IMS.doc.sets.push({ attrs: [["Name", name], ["File", file]], raw: null, images: [], rawClose: null, selfClosing: false });
|
||||
renderSets();
|
||||
await selectSet(IMS.doc.sets.length - 1);
|
||||
$("ims-sets").lastChild?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
$("ims-del-set").onclick = async () => {
|
||||
const s = curSet();
|
||||
if (!s) return;
|
||||
if (!(await confirmBox("Delete imageset", `Delete the imageset “${attr(s, "Name")}” and its ${images(s).length} images?`, "Delete", "danger"))) return;
|
||||
snapshot();
|
||||
IMS.doc.sets.splice(IMS.set, 1);
|
||||
IMS.set = Math.min(IMS.set, IMS.doc.sets.length - 1);
|
||||
renderSets();
|
||||
if (IMS.set >= 0) selectSet(IMS.set);
|
||||
else renderAll();
|
||||
};
|
||||
|
||||
// ---------- canvas ----------
|
||||
const canvas = $("ims-canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
let checker = null;
|
||||
|
||||
function resizeCanvas() {
|
||||
const r = canvas.parentElement.getBoundingClientRect();
|
||||
const dpr = devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.round(r.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(r.height * dpr));
|
||||
canvas.style.width = `${r.width}px`;
|
||||
canvas.style.height = `${r.height}px`;
|
||||
drawCanvas();
|
||||
}
|
||||
new ResizeObserver(resizeCanvas).observe(canvas.parentElement);
|
||||
|
||||
function fitView() {
|
||||
const t = IMS.cur;
|
||||
const r = canvas.parentElement.getBoundingClientRect();
|
||||
if (!t) {
|
||||
IMS.zoom = 1;
|
||||
IMS.panX = IMS.panY = 20;
|
||||
return updateZoomLabel();
|
||||
}
|
||||
IMS.zoom = Math.max(0.05, Math.min(8, Math.min((r.width - 40) / t.w, (r.height - 40) / t.h)));
|
||||
IMS.panX = (r.width - t.w * IMS.zoom) / 2;
|
||||
IMS.panY = (r.height - t.h * IMS.zoom) / 2;
|
||||
updateZoomLabel();
|
||||
}
|
||||
function setZoom(z, cx, cy) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const x = cx ?? r.width / 2;
|
||||
const y = cy ?? r.height / 2;
|
||||
const nz = Math.max(0.05, Math.min(16, z));
|
||||
IMS.panX = x - ((x - IMS.panX) * nz) / IMS.zoom;
|
||||
IMS.panY = y - ((y - IMS.panY) * nz) / IMS.zoom;
|
||||
IMS.zoom = nz;
|
||||
updateZoomLabel();
|
||||
drawCanvas();
|
||||
}
|
||||
const updateZoomLabel = () => ($("ims-zoom").textContent = `${Math.round(IMS.zoom * 100)}%`);
|
||||
$("ims-zoom-in").onclick = () => setZoom(IMS.zoom * 1.25);
|
||||
$("ims-zoom-out").onclick = () => setZoom(IMS.zoom / 1.25);
|
||||
$("ims-zoom").onclick = () => setZoom(1);
|
||||
$("ims-fit").onclick = () => (fitView(), drawCanvas());
|
||||
$("ims-showall").onclick = () => {
|
||||
IMS.showAll = !IMS.showAll;
|
||||
$("ims-showall").classList.toggle("on", IMS.showAll);
|
||||
drawCanvas();
|
||||
};
|
||||
$("ims-drawmode").onclick = () => {
|
||||
IMS.draw = !IMS.draw;
|
||||
$("ims-drawmode").classList.toggle("on", IMS.draw);
|
||||
canvas.classList.toggle("drawing", IMS.draw);
|
||||
};
|
||||
|
||||
function drawCanvas() {
|
||||
const dpr = devicePixelRatio || 1;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.setTransform(dpr * IMS.zoom, 0, 0, dpr * IMS.zoom, dpr * IMS.panX, dpr * IMS.panY);
|
||||
const t = IMS.cur;
|
||||
const s = curSet();
|
||||
if (t) {
|
||||
if (!checker) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = c.height = 16;
|
||||
const g = c.getContext("2d");
|
||||
g.fillStyle = "#2a2830";
|
||||
g.fillRect(0, 0, 16, 16);
|
||||
g.fillStyle = "#34313b";
|
||||
g.fillRect(0, 0, 8, 8);
|
||||
g.fillRect(8, 8, 8, 8);
|
||||
checker = ctx.createPattern(c, "repeat");
|
||||
}
|
||||
ctx.fillStyle = checker;
|
||||
ctx.fillRect(0, 0, t.w, t.h);
|
||||
ctx.imageSmoothingEnabled = IMS.zoom < 1;
|
||||
ctx.drawImage(t.bitmap, 0, 0);
|
||||
}
|
||||
if (!s) return;
|
||||
const px = 1 / IMS.zoom;
|
||||
const list = images(s);
|
||||
list.forEach((im, i) => {
|
||||
if (i === IMS.img || (!IMS.showAll && i !== IMS.hover)) return;
|
||||
const r = rectOf(im);
|
||||
ctx.lineWidth = px;
|
||||
ctx.strokeStyle = i === IMS.hover ? "rgba(255,255,255,.95)" : "rgba(233,180,60,.55)";
|
||||
ctx.strokeRect(r.x + px / 2, r.y + px / 2, r.w - px, r.h - px);
|
||||
});
|
||||
const sel = curImg();
|
||||
if (sel) {
|
||||
const r = rectOf(sel);
|
||||
ctx.fillStyle = "rgba(233,180,60,.18)";
|
||||
ctx.fillRect(r.x, r.y, r.w, r.h);
|
||||
ctx.lineWidth = 2 * px;
|
||||
ctx.strokeStyle = "#f4c95a";
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||
// offset point (OffsetX/Y: the image's pivot)
|
||||
if (attr(sel, "OffsetX") !== "" || attr(sel, "OffsetY") !== "") {
|
||||
const ox = r.x + num(sel, "OffsetX");
|
||||
const oy = r.y + num(sel, "OffsetY");
|
||||
ctx.strokeStyle = "#ff6b5e";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ox - 5 * px, oy);
|
||||
ctx.lineTo(ox + 5 * px, oy);
|
||||
ctx.moveTo(ox, oy - 5 * px);
|
||||
ctx.lineTo(ox, oy + 5 * px);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "#f4c95a";
|
||||
for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px);
|
||||
}
|
||||
if (IMS.drawRect) {
|
||||
const r = IMS.drawRect;
|
||||
ctx.lineWidth = px;
|
||||
ctx.setLineDash([4 * px, 3 * px]);
|
||||
ctx.strokeStyle = "#3ec07a";
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
const handles = (r) => [
|
||||
[r.x, r.y, "nw"],
|
||||
[r.x + r.w / 2, r.y, "n"],
|
||||
[r.x + r.w, r.y, "ne"],
|
||||
[r.x + r.w, r.y + r.h / 2, "e"],
|
||||
[r.x + r.w, r.y + r.h, "se"],
|
||||
[r.x + r.w / 2, r.y + r.h, "s"],
|
||||
[r.x, r.y + r.h, "sw"],
|
||||
[r.x, r.y + r.h / 2, "w"],
|
||||
];
|
||||
const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
|
||||
|
||||
function toTex(e) {
|
||||
const b = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - b.left - IMS.panX) / IMS.zoom, y: (e.clientY - b.top - IMS.panY) / IMS.zoom };
|
||||
}
|
||||
function hitHandle(p) {
|
||||
const sel = curImg();
|
||||
if (!sel) return null;
|
||||
const tol = 6 / IMS.zoom;
|
||||
return handles(rectOf(sel)).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null;
|
||||
}
|
||||
/** Smallest box under the point (so small icons inside bigger ones can be picked). */
|
||||
function hitImage(p) {
|
||||
const s = curSet();
|
||||
if (!s) return -1;
|
||||
let best = -1;
|
||||
let area = Infinity;
|
||||
images(s).forEach((im, i) => {
|
||||
const r = rectOf(im);
|
||||
if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h < area) {
|
||||
best = i;
|
||||
area = r.w * r.h;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
let drag = null;
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
if (!curSet()) return;
|
||||
canvas.focus();
|
||||
const p = toTex(e);
|
||||
if (e.button === 1 || e.button === 2 || (e.button === 0 && e.altKey)) {
|
||||
drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY };
|
||||
} else if (IMS.draw) {
|
||||
drag = { mode: "draw", start: { x: Math.round(p.x), y: Math.round(p.y) } };
|
||||
} else {
|
||||
const h = hitHandle(p);
|
||||
if (h) {
|
||||
snapshot();
|
||||
drag = { mode: "resize", h, start: p, r: rectOf(curImg()) };
|
||||
} else {
|
||||
const i = hitImage(p);
|
||||
if (i >= 0) {
|
||||
if (i !== IMS.img) selectImg(i);
|
||||
snapshot();
|
||||
drag = { mode: "move", start: p, r: rectOf(curImg()), moved: false };
|
||||
} else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY };
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!$("view-ui").classList.contains("on")) return;
|
||||
if (!drag) {
|
||||
if (e.target !== canvas || !curSet()) return;
|
||||
const p = toTex(e);
|
||||
const h = !IMS.draw && hitHandle(p);
|
||||
canvas.style.cursor = IMS.draw ? "crosshair" : h ? CURSOR[h] : hitImage(p) >= 0 ? "move" : "grab";
|
||||
const hov = hitImage(p);
|
||||
if (hov !== IMS.hover) {
|
||||
IMS.hover = hov;
|
||||
$("ims-hover").textContent = hov >= 0 ? `${attr(images(curSet())[hov], "Name")} ${Math.floor(p.x)}, ${Math.floor(p.y)}` : `${Math.floor(p.x)}, ${Math.floor(p.y)}`;
|
||||
drawCanvas();
|
||||
} else $("ims-hover").textContent = `${hov >= 0 ? attr(images(curSet())[hov], "Name") + " " : ""}${Math.floor(p.x)}, ${Math.floor(p.y)}`;
|
||||
return;
|
||||
}
|
||||
if (drag.mode === "pan") {
|
||||
IMS.panX = drag.px + e.clientX - drag.sx;
|
||||
IMS.panY = drag.py + e.clientY - drag.sy;
|
||||
canvas.style.cursor = "grabbing";
|
||||
return drawCanvas();
|
||||
}
|
||||
const p = toTex(e);
|
||||
if (drag.mode === "draw") {
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(p.y);
|
||||
IMS.drawRect = { x: Math.min(x, drag.start.x), y: Math.min(y, drag.start.y), w: Math.abs(x - drag.start.x), h: Math.abs(y - drag.start.y) };
|
||||
return drawCanvas();
|
||||
}
|
||||
const im = curImg();
|
||||
const dx = Math.round(p.x - drag.start.x);
|
||||
const dy = Math.round(p.y - drag.start.y);
|
||||
let { x, y, w, h } = drag.r;
|
||||
if (drag.mode === "move") {
|
||||
x += dx;
|
||||
y += dy;
|
||||
drag.moved = drag.moved || dx || dy;
|
||||
} else {
|
||||
if (drag.h.includes("w")) (x += dx), (w -= dx);
|
||||
if (drag.h.includes("e")) w += dx;
|
||||
if (drag.h.includes("n")) (y += dy), (h -= dy);
|
||||
if (drag.h.includes("s")) h += dy;
|
||||
if (w < 1) (x += w - 1), (w = 1);
|
||||
if (h < 1) (y += h - 1), (h = 1);
|
||||
}
|
||||
setAttr(im, "X", x);
|
||||
setAttr(im, "Y", y);
|
||||
setAttr(im, "Width", w);
|
||||
setAttr(im, "Height", h);
|
||||
drawCanvas();
|
||||
$("ims-hover").textContent = `${attr(im, "Name")} X ${x} Y ${y} ${w} × ${h}`;
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!drag) return;
|
||||
const d = drag;
|
||||
drag = null;
|
||||
canvas.style.cursor = "";
|
||||
if (d.mode === "draw" && IMS.drawRect) {
|
||||
const r = IMS.drawRect;
|
||||
IMS.drawRect = null;
|
||||
if (r.w >= 2 && r.h >= 2) addImage(r);
|
||||
else drawCanvas();
|
||||
} else if (d.mode === "move" || d.mode === "resize") {
|
||||
if (d.mode === "move" && !d.moved) IMS.undo.pop(); // just a click: nothing changed
|
||||
renderImages();
|
||||
renderImgForm();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const b = canvas.getBoundingClientRect();
|
||||
setZoom(IMS.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15), e.clientX - b.left, e.clientY - b.top);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
// Keyboard: arrows nudge (Shift = 10 px), Delete removes, Ctrl+Z / Ctrl+Y, Ctrl+S saves
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!$("view-ui").classList.contains("on") || !IMS.doc) return;
|
||||
if (e.target.matches?.("input, textarea, select")) return;
|
||||
if (e.ctrlKey && e.key.toLowerCase() === "z") return e.preventDefault(), undo();
|
||||
if (e.ctrlKey && e.key.toLowerCase() === "y") return e.preventDefault(), redo();
|
||||
if (e.ctrlKey && e.key.toLowerCase() === "s") return e.preventDefault(), IMS.source?.kind === "pack" ? saveToPack() : saveToFile();
|
||||
const im = curImg();
|
||||
if (!im) return;
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
|
||||
if (move) {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) snapshot();
|
||||
setAttr(im, "X", num(im, "X") + move[0]);
|
||||
setAttr(im, "Y", num(im, "Y") + move[1]);
|
||||
drawCanvas();
|
||||
renderImgForm();
|
||||
} else if (e.key === "Delete") deleteImage();
|
||||
});
|
||||
|
||||
function renderAll() {
|
||||
renderSets();
|
||||
renderSide();
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
// Refresh the pack list whenever the view opens
|
||||
document.querySelector('[data-view="ui"]').addEventListener("click", () => imsSessions());
|
||||
@@ -0,0 +1,232 @@
|
||||
"use strict";
|
||||
|
||||
// Items tab: every item of the game, read from its packs by src/main/items.js (names from the text
|
||||
// table, icons from xml/uiimageset.xml through UIRes, prices, box contents, descriptions). It reads
|
||||
// the packs as they are, so it always shows the current rs.iop / map.iop; Reload reads them again.
|
||||
|
||||
(() => {
|
||||
const IT = { src: null, data: null, cat: "etc", text: null, table: null, loading: false, detailSeq: 0 };
|
||||
window.IT = IT; // (dev probes)
|
||||
const n = (v) => Number(v || 0).toLocaleString();
|
||||
const PRICE = (s) => (s.cash ? `${n(s.cash)} cash` : s.peso ? `${n(s.peso)} peso` : "");
|
||||
|
||||
// ---------- icons (UIRes: imageset → texture, same drawing as the window editor) ----------
|
||||
function iconCanvas(name, px) {
|
||||
const c = el("canvas", { class: "it-icon", width: px, height: px });
|
||||
c.style.width = `${px}px`;
|
||||
c.style.height = `${px}px`;
|
||||
if (name) drawIcon(c, name, px);
|
||||
return c;
|
||||
}
|
||||
async function drawIcon(c, name, px) {
|
||||
const i = name.indexOf("#");
|
||||
const im = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name);
|
||||
if (!im?.bmp) {
|
||||
c.classList.add("missing");
|
||||
c.title = `${name} (not found)`;
|
||||
return;
|
||||
}
|
||||
const ctx = c.getContext("2d");
|
||||
const scale = Math.min(px / im.w, px / im.h);
|
||||
const w = Math.round(im.w * scale);
|
||||
const h = Math.round(im.h * scale);
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, (px - w) / 2, (px - h) / 2, w, h);
|
||||
c.title = name;
|
||||
}
|
||||
|
||||
// ---------- list ----------
|
||||
function table() {
|
||||
if (IT.table) return IT.table;
|
||||
IT.table = new VTable(
|
||||
$("it-table"),
|
||||
[
|
||||
{ key: "icon", label: "", width: 40, format: (r) => iconCanvas(r.icon, 30) },
|
||||
{ key: "id", label: "ID", width: 90, cls: "num mono" },
|
||||
{ key: "name", label: "Name", grow: true, width: 200, format: (r) => r.name || "(no name)", sort: (r) => r.name || "", title: true },
|
||||
{ key: "kind", label: "Type", width: 110, title: true },
|
||||
{ key: "price", label: "Price", width: 120, cls: "num", format: (r) => (r.shop?.length ? PRICE(r.shop.find((s) => s.cash || s.peso) || r.shop[0]) : ""), sort: (r) => (r.shop?.[0]?.cash || 0) * 1e6 + (r.shop?.[0]?.peso || 0) },
|
||||
],
|
||||
{ multi: false, rowHeight: 36, id: "items" },
|
||||
);
|
||||
IT.table.keyOf = (r) => r.index;
|
||||
IT.table.onSelect = (_sel, r) => r && showDetail(r);
|
||||
return IT.table;
|
||||
}
|
||||
|
||||
function renderCats() {
|
||||
const nav = $("it-cats");
|
||||
nav.replaceChildren(
|
||||
...IT.data.categories.map((c) =>
|
||||
el("button", { class: c.id === IT.cat ? "on" : "", title: c.error || "", onclick: () => pickCat(c.id) }, [
|
||||
el("span", { text: c.label }),
|
||||
el("b", { text: c.error ? "!" : n(c.items.filter(shown).length) }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const shown = (it) => !$("it-hide").checked || (it.name && !/^STR\(/i.test(it.name));
|
||||
|
||||
function pickCat(id) {
|
||||
IT.cat = id;
|
||||
renderCats();
|
||||
const c = IT.data.categories.find((x) => x.id === id);
|
||||
table().setRows(c.items.map((it, index) => ({ ...it, index })), { animate: "fade" });
|
||||
filter();
|
||||
$("it-detail").replaceChildren(el("div", { class: "it-empty muted", text: c.error ? `Couldn't read this category: ${c.error}` : "Pick an item." }));
|
||||
}
|
||||
|
||||
function filter() {
|
||||
const q = $("it-search").value.trim().toLowerCase();
|
||||
table().setView((r) => shown(r) && (!q || String(r.id).includes(q) || (r.name || "").toLowerCase().includes(q) || (r.icon || "").toLowerCase().includes(q) || (r.section || "").toLowerCase().includes(q)));
|
||||
$("it-count").textContent = `${n(table().view.length)} of ${n(table().rows.length)}`;
|
||||
}
|
||||
|
||||
// ---------- detail ----------
|
||||
function row(label, value) {
|
||||
return value === undefined || value === null || value === "" ? null : el("div", { class: "it-kv" }, [el("span", { class: "muted", text: label }), el("span", { text: String(value) })]);
|
||||
}
|
||||
|
||||
async function showDetail(r) {
|
||||
const seq = ++IT.detailSeq;
|
||||
const box = $("it-detail");
|
||||
const shop = (r.shop || []).filter((s) => s.cash || s.peso || s.value || s.period);
|
||||
box.replaceChildren(
|
||||
el("div", { class: "it-head" }, [
|
||||
iconCanvas(r.icon, 72),
|
||||
el("div", {}, [
|
||||
el("h3", { text: r.name || "(no name)" }),
|
||||
el("div", { class: "muted", text: `${IT.data.categories.find((c) => c.id === IT.cat)?.label} · ID ${r.id}` }),
|
||||
el("code", { class: "muted", text: r.icon || "no icon" }),
|
||||
]),
|
||||
]),
|
||||
el("div", { class: "it-kvs" }, [
|
||||
row("Original", r.originalName),
|
||||
row("Type", r.kind),
|
||||
row("Group", r.group),
|
||||
row("Grade", r.grade || null),
|
||||
row("Classes", r.enableClass),
|
||||
row("Level", r.needLevel || null),
|
||||
row("Sell price", r.sellPeso ? `${n(r.sellPeso)} peso` : r.canSell === false ? "can't be sold" : null),
|
||||
row("Active", r.active === null || r.active === undefined ? null : r.active ? "yes" : "no"),
|
||||
row("Source", `${r.file} [${r.section}]`),
|
||||
].filter(Boolean)),
|
||||
...(shop.length
|
||||
? [
|
||||
el("h4", { text: "Shop" }),
|
||||
el("table", { class: "it-shop" }, [
|
||||
el("tr", {}, ["Item", "Count / days", "Peso", "Cash", "Bonus peso", "On sale"].map((h) => el("th", { text: h }))),
|
||||
...shop.map((s) =>
|
||||
el("tr", { class: s.active === false ? "off" : "" }, [
|
||||
el("td", { text: s.label || "" }),
|
||||
el("td", { text: s.period ? `${n(s.period)} h` : s.value ? n(s.value) : "" }),
|
||||
el("td", { class: "num", text: s.peso ? n(s.peso) : "" }),
|
||||
el("td", { class: "num", text: s.cash ? n(s.cash) : "" }),
|
||||
el("td", { class: "num", text: s.bonusPeso ? n(s.bonusPeso) : "" }),
|
||||
el("td", { text: s.active === null || s.active === undefined ? "" : s.active ? "yes" : "no" }),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
...(r.contents?.length
|
||||
? [
|
||||
el("h4", { text: `Contents (${r.contents.length})` }),
|
||||
el(
|
||||
"div",
|
||||
{ class: "it-contents" },
|
||||
r.contents.map((c) =>
|
||||
el("div", { class: "it-content" }, [
|
||||
iconCanvas(c.icon, 26),
|
||||
el("span", { text: c.name && !/^STR\(/i.test(c.name) ? c.name : `#${c.code}` }),
|
||||
el("span", { class: "muted num", text: c.percent !== null && c.percent !== undefined ? `${c.percent.toFixed(c.percent < 1 ? 2 : 1)}%` : "" }),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
el("div", { class: "it-manuals muted", text: r.manuals?.length ? "Loading the description…" : "" }),
|
||||
);
|
||||
let d = null;
|
||||
try {
|
||||
d = await call("items:detail", IT.src, IT.text, IT.cat, r.index);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
if (seq !== IT.detailSeq || !d) return;
|
||||
const manuals = box.querySelector(".it-manuals");
|
||||
manuals.classList.remove("muted");
|
||||
manuals.replaceChildren(
|
||||
...d.manuals.map((m) =>
|
||||
el("div", { class: "it-manual" }, [
|
||||
el("h4", { text: `${m.label} description` }, [el("span", { class: "muted", text: ` ${m.file} [Manual${m.n}]` })]),
|
||||
...(m.manual
|
||||
? [
|
||||
...(m.manual.title.length ? [el("div", { class: "it-mtitle" }, m.manual.title.map((x) => el("span", { text: x.text, style: x.color ? `color:${x.color}` : "" })))] : []),
|
||||
...m.manual.lines.map((l) => el("div", { class: "it-mline" }, l.runs.map((x) => el("span", { text: x.text, style: x.color ? `color:${x.color}` : "" })))),
|
||||
]
|
||||
: [el("div", { class: "muted", text: "(missing)" })]),
|
||||
]),
|
||||
),
|
||||
el("details", { class: "it-raw" }, [
|
||||
el("summary", { text: `All fields (${Object.keys(d.fields).length})` }),
|
||||
el("table", {}, Object.entries(d.fields).map(([k, v]) => el("tr", {}, [el("td", { class: "mono", text: k }), el("td", { class: "mono", text: v })]))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
async function load({ reload = false } = {}) {
|
||||
if (IT.loading) return;
|
||||
if (!IT.src) IT.src = profileSource();
|
||||
if (!IT.src) {
|
||||
$("it-source").textContent = "This profile has no game folder: set one in the profile, or pick a folder.";
|
||||
return;
|
||||
}
|
||||
IT.loading = true;
|
||||
$("it-source").textContent = `Reading ${IT.src.folder}…`;
|
||||
try {
|
||||
const [data] = await Promise.all([call("items:load", IT.src, { textFile: IT.text, reload }), UIRes.load(IT.src)]);
|
||||
IT.data = data;
|
||||
IT.text = data.text;
|
||||
$("it-text").replaceChildren(...data.texts.map((t) => el("option", { value: t.file, text: t.label, selected: t.file === data.text })));
|
||||
$("it-source").textContent = `${IT.src.folder} · ${n(data.strings)} texts from ${data.text || "no text table"}${data.missing.length ? ` · missing: ${data.missing.join(", ")}` : ""}`;
|
||||
if (!data.categories.some((c) => c.id === IT.cat)) IT.cat = data.categories[0]?.id;
|
||||
pickCat(IT.cat);
|
||||
} catch (err) {
|
||||
$("it-source").textContent = err.message;
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
IT.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("it-search").addEventListener("input", filter);
|
||||
$("it-hide").addEventListener("change", () => (renderCats(), filter()));
|
||||
$("it-text").addEventListener("change", () => {
|
||||
IT.text = $("it-text").value;
|
||||
load();
|
||||
});
|
||||
$("it-reload").addEventListener("click", () => {
|
||||
UIRes.reset();
|
||||
load({ reload: true });
|
||||
});
|
||||
$("it-folder").addEventListener("click", async () => {
|
||||
const dir = await call("dialog:folder", "Game folder (with rs.iop / map.iop)");
|
||||
if (!dir) return;
|
||||
IT.src = { kind: "packs", folder: dir, name: dir };
|
||||
IT.data = null;
|
||||
UIRes.reset();
|
||||
load();
|
||||
});
|
||||
// first time the tab opens: the profile's game folder
|
||||
document.querySelector('#tabs [data-view="items"]').addEventListener("click", () => {
|
||||
if (!IT.data) load();
|
||||
});
|
||||
window.addEventListener("profile-changed", () => {
|
||||
IT.src = null;
|
||||
IT.data = null;
|
||||
if ($("view-items").classList.contains("on")) load();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,710 @@
|
||||
"use strict";
|
||||
|
||||
// Lost Saga 3D formats (io3DEngine): .msh meshes, .skl skeletons, .ani animations, material
|
||||
// scripts and model configs. Little-endian, no compression; strings are int32 length + raw bytes
|
||||
// (CP949, no terminator). Readers keep the raw bytes of everything they don't understand or the
|
||||
// editor doesn't touch, so a save changes only what was edited.
|
||||
|
||||
const LSF = (() => {
|
||||
const cp949 = new TextDecoder("euc-kr");
|
||||
|
||||
class Reader {
|
||||
constructor(bytes) {
|
||||
this.b = bytes;
|
||||
this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
this.o = 0;
|
||||
}
|
||||
need(n) {
|
||||
if (this.o + n > this.b.length) throw new Error(`File ends early (offset ${this.o}, needs ${n} bytes)`);
|
||||
}
|
||||
i32() {
|
||||
this.need(4);
|
||||
const v = this.dv.getInt32(this.o, true);
|
||||
this.o += 4;
|
||||
return v;
|
||||
}
|
||||
u32() {
|
||||
this.need(4);
|
||||
const v = this.dv.getUint32(this.o, true);
|
||||
this.o += 4;
|
||||
return v;
|
||||
}
|
||||
f32() {
|
||||
this.need(4);
|
||||
const v = this.dv.getFloat32(this.o, true);
|
||||
this.o += 4;
|
||||
return v;
|
||||
}
|
||||
/** n floats as a Float32Array (copied, so it is aligned). */
|
||||
f32s(n) {
|
||||
this.need(n * 4);
|
||||
const out = new Float32Array(this.b.slice(this.o, this.o + n * 4).buffer);
|
||||
this.o += n * 4;
|
||||
return out;
|
||||
}
|
||||
u32s(n) {
|
||||
this.need(n * 4);
|
||||
const out = new Uint32Array(this.b.slice(this.o, this.o + n * 4).buffer);
|
||||
this.o += n * 4;
|
||||
return out;
|
||||
}
|
||||
str() {
|
||||
const n = this.i32();
|
||||
if (n < 0 || n > 4096) throw new Error(`Bad string length ${n} at offset ${this.o - 4}`);
|
||||
this.need(n);
|
||||
const raw = this.b.slice(this.o, this.o + n);
|
||||
this.o += n;
|
||||
return { raw, text: cp949.decode(raw) };
|
||||
}
|
||||
magic(m) {
|
||||
this.need(4);
|
||||
const s = String.fromCharCode(this.b[this.o], this.b[this.o + 1], this.b[this.o + 2]);
|
||||
if (s !== m || this.b[this.o + 3] !== 0) throw new Error(`Not a .${m.toLowerCase()} file`);
|
||||
this.o += 4;
|
||||
}
|
||||
}
|
||||
|
||||
class Writer {
|
||||
constructor(size = 1 << 16) {
|
||||
this.b = new Uint8Array(size);
|
||||
this.dv = new DataView(this.b.buffer);
|
||||
this.o = 0;
|
||||
}
|
||||
grow(n) {
|
||||
if (this.o + n <= this.b.length) return;
|
||||
let size = this.b.length * 2;
|
||||
while (size < this.o + n) size *= 2;
|
||||
const nb = new Uint8Array(size);
|
||||
nb.set(this.b);
|
||||
this.b = nb;
|
||||
this.dv = new DataView(nb.buffer);
|
||||
}
|
||||
i32(v) {
|
||||
this.grow(4);
|
||||
this.dv.setInt32(this.o, v, true);
|
||||
this.o += 4;
|
||||
}
|
||||
u32(v) {
|
||||
this.grow(4);
|
||||
this.dv.setUint32(this.o, v >>> 0, true);
|
||||
this.o += 4;
|
||||
}
|
||||
f32(v) {
|
||||
this.grow(4);
|
||||
this.dv.setFloat32(this.o, v, true);
|
||||
this.o += 4;
|
||||
}
|
||||
bytes(u8) {
|
||||
this.grow(u8.length);
|
||||
this.b.set(u8, this.o);
|
||||
this.o += u8.length;
|
||||
}
|
||||
/** A string: its original bytes when the text wasn't changed, else ASCII. */
|
||||
str(s) {
|
||||
const raw = s.raw && cp949.decode(s.raw) === s.text ? s.raw : ascii(s.text);
|
||||
this.i32(raw.length);
|
||||
this.bytes(raw);
|
||||
}
|
||||
done() {
|
||||
return this.b.slice(0, this.o);
|
||||
}
|
||||
}
|
||||
|
||||
function ascii(text) {
|
||||
const out = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text.charCodeAt(i);
|
||||
if (c > 0x7e || c < 0x20) throw new Error(`“${text}”: only plain ASCII text can be written here.`);
|
||||
out[i] = c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const S = (text) => ({ raw: null, text }); // a new string (written as ASCII)
|
||||
|
||||
// ---------- .msh ----------
|
||||
const MESH_TYPES = ["Static", "Animated", "Lightmap", "Billboard", "Normal billboard", "Animate effect", "Static vertex colour"];
|
||||
const FVF = { POSITION: 0x1, POSITION2: 0x2, WEIGHTS: 0x8, INDICES: 0x10, NORMAL: 0x20, COLOR0: 0x40, UV0: 0x100, UV1: 0x200, TANGENT: 0x1000, BINORMAL: 0x2000 };
|
||||
|
||||
function parseMsh(bytes) {
|
||||
const r = new Reader(bytes);
|
||||
r.magic("MSH");
|
||||
const m = { version: r.i32(), type: r.i32(), mask: r.u32() };
|
||||
m.aabbMin = [r.f32(), r.f32(), r.f32()];
|
||||
m.aabbMax = [r.f32(), r.f32(), r.f32()];
|
||||
m.radius = r.f32();
|
||||
const subCount = r.i32();
|
||||
if (subCount < 0 || subCount > 4096) throw new Error("Bad sub-mesh count");
|
||||
m.subs = [];
|
||||
for (let i = 0; i < subCount; i++) m.subs.push({ minIndex: r.i32(), vertexCount: r.i32(), indexStart: r.i32(), faceCount: r.i32() });
|
||||
const V = (m.vertexCount = r.i32());
|
||||
if (V < 0 || V > 4e6) throw new Error("Bad vertex count");
|
||||
const k = m.mask;
|
||||
if (k & FVF.POSITION) m.pos = r.f32s(V * 3);
|
||||
if (k & FVF.NORMAL) m.normal = r.f32s(V * 3);
|
||||
if (k & FVF.TANGENT) m.tangent = r.f32s(V * 3);
|
||||
if (k & FVF.BINORMAL) m.binormal = r.f32s(V * 3);
|
||||
if (k & FVF.COLOR0) m.color = r.u32s(V);
|
||||
if (k & FVF.UV0) m.uv0 = r.f32s(V * 2);
|
||||
if (k & FVF.UV1) m.uv1 = r.f32s(V * 2);
|
||||
if (k & FVF.WEIGHTS) {
|
||||
const n = r.i32();
|
||||
if (n < 0 || n > 1024) throw new Error("Bad bone count");
|
||||
m.boneNames = [];
|
||||
for (let i = 0; i < n; i++) m.boneNames.push(r.str());
|
||||
// per vertex: weight[4], boneIndex[4] (indices stored as floats)
|
||||
const blk = r.f32s(V * 8);
|
||||
m.weights = new Float32Array(V * 4);
|
||||
m.boneIdx = new Uint8Array(V * 4);
|
||||
for (let v = 0; v < V; v++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
m.weights[v * 4 + j] = blk[v * 8 + j];
|
||||
m.boneIdx[v * 4 + j] = Math.max(0, Math.min(255, Math.round(blk[v * 8 + 4 + j])));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (k & FVF.POSITION2) m.pos2 = r.f32s(V * 3);
|
||||
const F = (m.faceCount = r.i32());
|
||||
if (F < 0 || F > 8e6) throw new Error("Bad face count");
|
||||
const at = r.o;
|
||||
// Index width: 16-bit in the client files we checked; some newer engines read 32-bit.
|
||||
// Pick the one whose end lines up with the file (after the control points for v2002).
|
||||
const tryWidth = (w) => {
|
||||
const end = at + F * 3 * w;
|
||||
if (end > bytes.length) return null;
|
||||
if (m.version !== 2002) return end === bytes.length ? { end, cps: [] } : null;
|
||||
try {
|
||||
const rr = new Reader(bytes);
|
||||
rr.o = end;
|
||||
const cps = readControlPoints(rr);
|
||||
return rr.o === bytes.length ? { end, cps } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
let hit = tryWidth(2);
|
||||
m.index32 = false;
|
||||
if (!hit && (hit = tryWidth(4))) m.index32 = true;
|
||||
if (!hit) {
|
||||
// unexpected trailing data: read 16-bit and keep the rest as is
|
||||
hit = { end: at + F * 6, cps: [], tail: true };
|
||||
if (hit.end > bytes.length) throw new Error("File ends inside the index list");
|
||||
}
|
||||
const ib = bytes.slice(at, hit.end).buffer;
|
||||
m.indices = m.index32 ? new Uint32Array(ib) : new Uint16Array(ib);
|
||||
m.bodyEnd = hit.end; // everything before the control points
|
||||
m.controlPoints = hit.cps;
|
||||
m.trailing = hit.tail ? bytes.slice(hit.end) : null;
|
||||
m.bytes = bytes;
|
||||
return m;
|
||||
}
|
||||
|
||||
function readControlPoints(r) {
|
||||
const n = r.i32();
|
||||
if (n < 0 || n > 10000) throw new Error("Bad control point count");
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) out.push({ name: r.str(), bone: r.str(), extra: r.str(), point: [r.f32(), r.f32(), r.f32()] });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The mesh with its control points rewritten (geometry bytes kept); v2000/2001 become 2002. */
|
||||
function writeMsh(m) {
|
||||
const w = new Writer(m.bodyEnd + 256);
|
||||
w.bytes(m.bytes.subarray(0, m.bodyEnd));
|
||||
if (m.trailing) {
|
||||
w.bytes(m.trailing);
|
||||
return w.done();
|
||||
}
|
||||
const cps = m.controlPoints || [];
|
||||
if (m.version !== 2002 && !cps.length) return w.done();
|
||||
w.dv.setInt32(4, 2002, true);
|
||||
w.i32(cps.length);
|
||||
for (const c of cps) {
|
||||
w.str(c.name);
|
||||
w.str(c.bone);
|
||||
w.str(c.extra);
|
||||
for (const v of c.point) w.f32(v);
|
||||
}
|
||||
return w.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole new mesh from its fields (the Costume tab's imports): { type, subs, pos, normal, uv0,
|
||||
* boneNames: [text], weights (4 per vertex, biggest first), boneIdx, indices }. Writes v2000 with
|
||||
* 16-bit indices like every client mesh; the box and radius are computed.
|
||||
*/
|
||||
function buildMsh(m) {
|
||||
const V = m.pos.length / 3;
|
||||
if (m.indices.length && Math.max(...m.indices) >= 65536) throw new Error("More than 65 536 vertices: split the mesh.");
|
||||
let mask = FVF.POSITION | FVF.UV0;
|
||||
if (m.normal) mask |= FVF.NORMAL;
|
||||
if (m.boneNames?.length) mask |= FVF.WEIGHTS | FVF.INDICES;
|
||||
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let v = 0; v < V; v++) for (let k = 0; k < 3; k++) {
|
||||
min[k] = Math.min(min[k], m.pos[v * 3 + k]);
|
||||
max[k] = Math.max(max[k], m.pos[v * 3 + k]);
|
||||
}
|
||||
if (!V) min.fill(0), max.fill(0);
|
||||
const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; // half the box diagonal, like the exporter
|
||||
const w = new Writer(64 + V * 80 + m.indices.length * 2);
|
||||
w.bytes(Uint8Array.of(0x4d, 0x53, 0x48, 0)); // "MSH\0"
|
||||
w.i32(2000);
|
||||
w.i32(m.type ?? 1);
|
||||
w.u32(mask);
|
||||
for (const v of [...min, ...max, radius]) w.f32(v);
|
||||
w.i32(m.subs.length);
|
||||
for (const s of m.subs) for (const v of [s.minIndex, s.vertexCount, s.indexStart, s.faceCount]) w.i32(v);
|
||||
w.i32(V);
|
||||
for (const v of m.pos) w.f32(v);
|
||||
if (m.normal) for (const v of m.normal) w.f32(v);
|
||||
for (const v of m.uv0) w.f32(v);
|
||||
if (mask & FVF.WEIGHTS) {
|
||||
w.i32(m.boneNames.length);
|
||||
for (const b of m.boneNames) w.str(typeof b === "string" ? S(b) : b); // text, or a read / built { raw, text }
|
||||
// per vertex: weight[4], boneIndex[4] as floats; the engine stops at the first zero weight
|
||||
for (let v = 0; v < V; v++) {
|
||||
for (let j = 0; j < 4; j++) w.f32(m.weights[v * 4 + j]);
|
||||
for (let j = 0; j < 4; j++) w.f32(m.boneIdx[v * 4 + j]);
|
||||
}
|
||||
}
|
||||
w.i32(m.indices.length / 3);
|
||||
w.grow(m.indices.length * 2);
|
||||
for (const i of m.indices) {
|
||||
w.dv.setUint16(w.o, i, true);
|
||||
w.o += 2;
|
||||
}
|
||||
return w.done();
|
||||
}
|
||||
|
||||
// ---------- .skl ----------
|
||||
function parseSkl(bytes) {
|
||||
const r = new Reader(bytes);
|
||||
r.magic("SKL");
|
||||
const s = { version: r.i32(), bones: [] };
|
||||
const n = r.i32();
|
||||
if (n < 0 || n > 4096) throw new Error("Bad bone count");
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = { name: r.str() };
|
||||
b.localPos = [r.f32(), r.f32(), r.f32()];
|
||||
b.localRot = [r.f32(), r.f32(), r.f32(), r.f32()];
|
||||
b.invPos = [r.f32(), r.f32(), r.f32()];
|
||||
b.invRot = [r.f32(), r.f32(), r.f32(), r.f32()];
|
||||
b.objectTM = Array.from(r.f32s(16));
|
||||
b.parent = r.str();
|
||||
const c = r.i32();
|
||||
b.children = [];
|
||||
for (let j = 0; j < c; j++) b.children.push(r.str().text);
|
||||
s.bones.push(b);
|
||||
}
|
||||
s.linked = r.o < bytes.length ? r.str().text : "";
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- .ani ----------
|
||||
const ANI_VERSIONS = { 4000: "full rotations (16 bytes)", 4001: "small-three rotations (4 bytes)", 4002: "8-byte rotations" };
|
||||
|
||||
/** 4001: index of the largest component in bits 31-30, the other three as 10-bit fields. */
|
||||
function smallThree(dw, out, o) {
|
||||
const max = dw >>> 30;
|
||||
const a = 2 * (((dw >>> 20) & 1023) / 1023) - 1;
|
||||
const b = 2 * (((dw >>> 10) & 1023) / 1023) - 1;
|
||||
const c = 2 * ((dw & 1023) / 1023) - 1;
|
||||
const extra = Math.sqrt(Math.max(0, 1 - (a * a + b * b + c * c)));
|
||||
const vals = [a, b, c];
|
||||
for (let j = 0, k = 0; j < 4; j++) out[o + j] = j === max ? extra : vals[k++];
|
||||
}
|
||||
function eightBytes(hi, lo, out, o) {
|
||||
out[o] = ((hi >>> 16) / 65535) * 2 - 1;
|
||||
out[o + 1] = ((hi & 0xffff) / 65535) * 2 - 1;
|
||||
out[o + 2] = ((lo >>> 16) / 65535) * 2 - 1;
|
||||
out[o + 3] = (lo & 0xffff) / 65535;
|
||||
}
|
||||
|
||||
/** QComp::CompSmallThree / Comp8Bytes (QuaternionCompression.cpp), the inverse of the two above. */
|
||||
function compSmallThree(q) {
|
||||
let mx = 0;
|
||||
for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[mx])) mx = i;
|
||||
const s = q[mx] < 0 ? -1 : 1;
|
||||
let dw = mx << 30;
|
||||
for (let i = 0, k = 0; i < 4; i++) {
|
||||
if (i === mx) continue;
|
||||
const v = Math.trunc(((s * q[i] + 1) * 0.5) * 1023);
|
||||
dw |= Math.max(0, Math.min(v, 1023)) << (10 * (2 - k++));
|
||||
}
|
||||
return dw >>> 0;
|
||||
}
|
||||
function compEight(q) {
|
||||
const s = q[3] < 0 ? -1 : 1;
|
||||
const c = (v) => Math.max(0, Math.min(Math.trunc(((s * v + 1) * 0.5) * 65535), 65535));
|
||||
return [((c(q[0]) << 16) | c(q[1])) >>> 0, ((c(q[2]) << 16) | Math.max(0, Math.min(Math.trunc(s * q[3] * 65535), 65535))) >>> 0];
|
||||
}
|
||||
/** Stored rotation words of a (normalised) quaternion for an .ani version. */
|
||||
function encodeRot(version, q) {
|
||||
const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
|
||||
const n = [q[0] / len, q[1] / len, q[2] / len, q[3] / len];
|
||||
if (version === 4001) return [compSmallThree(n)];
|
||||
if (version === 4002) return compEight(n);
|
||||
const dv = new DataView(new ArrayBuffer(16));
|
||||
n.forEach((v, j) => dv.setFloat32(j * 4, v, true));
|
||||
return [0, 1, 2, 3].map((j) => dv.getUint32(j * 4, true));
|
||||
}
|
||||
/** What a stored rotation reads back as (to show edits with the precision they'll have in the game). */
|
||||
function decodeRot(version, words) {
|
||||
const out = [0, 0, 0, 1];
|
||||
if (version === 4001) smallThree(words[0], out, 0);
|
||||
else if (version === 4002) eightBytes(words[0], words[1], out, 0);
|
||||
else {
|
||||
const dv = new DataView(new ArrayBuffer(4));
|
||||
for (let j = 0; j < 4; j++) {
|
||||
dv.setUint32(0, words[j], true);
|
||||
out[j] = dv.getFloat32(0, true);
|
||||
}
|
||||
}
|
||||
const len = Math.hypot(...out) || 1;
|
||||
return out.map((v) => v / len);
|
||||
}
|
||||
|
||||
function parseAni(bytes) {
|
||||
const r = new Reader(bytes);
|
||||
r.magic("ANI");
|
||||
const a = { version: r.i32(), events: [], tracks: [] };
|
||||
if (!ANI_VERSIONS[a.version]) throw new Error(`Unknown .ani version ${a.version}`);
|
||||
const ne = r.i32();
|
||||
if (ne < 0 || ne > 100000) throw new Error("Bad event count");
|
||||
for (let i = 0; i < ne; i++) a.events.push({ type: r.str(), sub: r.str(), time: r.f32() });
|
||||
a.total = r.i32();
|
||||
const nt = r.i32();
|
||||
if (nt < 0 || nt > 4096) throw new Error("Bad track count");
|
||||
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4; // 4000: four raw floats
|
||||
const bits = new DataView(new ArrayBuffer(4));
|
||||
for (let i = 0; i < nt; i++) {
|
||||
const t = { name: r.str(), weight: r.f32() };
|
||||
const n = r.i32();
|
||||
if (n < 0 || n > 1e6) throw new Error("Bad key count");
|
||||
t.times = new Int32Array(n);
|
||||
t.trans = new Float32Array(n * 3);
|
||||
t.rot = new Float32Array(n * 4);
|
||||
t.rawRot = new Uint32Array(n * words); // stored rotation words, written back untouched
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (words === 4)
|
||||
for (let j = 0; j < 4; j++) {
|
||||
bits.setUint32(0, (t.rawRot[k * 4 + j] = r.u32()), true);
|
||||
t.rot[k * 4 + j] = bits.getFloat32(0, true);
|
||||
}
|
||||
else if (words === 1) smallThree((t.rawRot[k] = r.u32()), t.rot, k * 4);
|
||||
else eightBytes((t.rawRot[k * 2] = r.u32()), (t.rawRot[k * 2 + 1] = r.u32()), t.rot, k * 4);
|
||||
const q = t.rot.subarray(k * 4, k * 4 + 4);
|
||||
const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
|
||||
for (let j = 0; j < 4; j++) q[j] /= len;
|
||||
t.trans[k * 3] = r.f32();
|
||||
t.trans[k * 3 + 1] = r.f32();
|
||||
t.trans[k * 3 + 2] = r.f32();
|
||||
t.times[k] = r.i32();
|
||||
}
|
||||
a.tracks.push(t);
|
||||
}
|
||||
if (r.o !== bytes.length) a.trailing = bytes.slice(r.o);
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Writes an animation; rotations keep their stored bytes (no re-compression). */
|
||||
function writeAni(a) {
|
||||
const w = new Writer(1 << 16);
|
||||
w.bytes(new Uint8Array([0x41, 0x4e, 0x49, 0]));
|
||||
w.i32(a.version);
|
||||
const events = a.events.slice().sort((x, y) => x.time - y.time);
|
||||
w.i32(events.length);
|
||||
for (const e of events) {
|
||||
w.str(e.type);
|
||||
w.str(e.sub);
|
||||
w.f32(e.time);
|
||||
}
|
||||
w.i32(Math.round(a.total));
|
||||
w.i32(a.tracks.length);
|
||||
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4;
|
||||
for (const t of a.tracks) {
|
||||
w.str(t.name);
|
||||
w.f32(t.weight);
|
||||
const n = t.times.length;
|
||||
w.i32(n);
|
||||
for (let k = 0; k < n; k++) {
|
||||
for (let j = 0; j < words; j++) w.u32(t.rawRot[k * words + j]);
|
||||
w.f32(t.trans[k * 3]);
|
||||
w.f32(t.trans[k * 3 + 1]);
|
||||
w.f32(t.trans[k * 3 + 2]);
|
||||
w.i32(t.times[k]);
|
||||
}
|
||||
}
|
||||
if (a.trailing) w.bytes(a.trailing);
|
||||
return w.done();
|
||||
}
|
||||
|
||||
/** Sampled local transform of a track at time t (ms): lerp position, slerp rotation. */
|
||||
function sampleTrack(t, time, pos, rot) {
|
||||
const n = t.times.length;
|
||||
if (!n) return false;
|
||||
let i = 0;
|
||||
while (i < n && t.times[i] <= time) i++;
|
||||
if (i === 0 || i === n) {
|
||||
const k = i === 0 ? 0 : n - 1;
|
||||
pos[0] = t.trans[k * 3];
|
||||
pos[1] = t.trans[k * 3 + 1];
|
||||
pos[2] = t.trans[k * 3 + 2];
|
||||
for (let j = 0; j < 4; j++) rot[j] = t.rot[k * 4 + j];
|
||||
return true;
|
||||
}
|
||||
const a = i - 1;
|
||||
const b = i;
|
||||
const span = t.times[b] - t.times[a];
|
||||
const f = span > 0 ? (time - t.times[a]) / span : 0;
|
||||
for (let j = 0; j < 3; j++) pos[j] = t.trans[a * 3 + j] + (t.trans[b * 3 + j] - t.trans[a * 3 + j]) * f;
|
||||
slerp(t.rot, a * 4, t.rot, b * 4, f, rot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function slerp(qa, oa, qb, ob, f, out) {
|
||||
let ax = qa[oa], ay = qa[oa + 1], az = qa[oa + 2], aw = qa[oa + 3];
|
||||
let bx = qb[ob], by = qb[ob + 1], bz = qb[ob + 2], bw = qb[ob + 3];
|
||||
let cos = ax * bx + ay * by + az * bz + aw * bw;
|
||||
if (cos < 0) {
|
||||
cos = -cos;
|
||||
bx = -bx;
|
||||
by = -by;
|
||||
bz = -bz;
|
||||
bw = -bw;
|
||||
}
|
||||
let s0, s1;
|
||||
if (cos > 0.9995) {
|
||||
s0 = 1 - f;
|
||||
s1 = f;
|
||||
} else {
|
||||
const th = Math.acos(cos);
|
||||
const sin = Math.sin(th);
|
||||
s0 = Math.sin((1 - f) * th) / sin;
|
||||
s1 = Math.sin(f * th) / sin;
|
||||
}
|
||||
out[0] = s0 * ax + s1 * bx;
|
||||
out[1] = s0 * ay + s1 * by;
|
||||
out[2] = s0 * az + s1 * bz;
|
||||
out[3] = s0 * aw + s1 * bw;
|
||||
const len = Math.hypot(out[0], out[1], out[2], out[3]) || 1;
|
||||
for (let j = 0; j < 4; j++) out[j] /= len;
|
||||
}
|
||||
|
||||
// ---------- material script (OGRE-like text) ----------
|
||||
/** → [{ passes: [{ shader, units: [{ texture, uvSet, light }], sceneBlend, alphaReject, cull, lighting }] }] */
|
||||
function parseMaterial(text) {
|
||||
const mats = [];
|
||||
const stack = [];
|
||||
let pendingBlock = null;
|
||||
let mat = null;
|
||||
let pass = null;
|
||||
let unit = null;
|
||||
for (let line of text.split(/\r\n|\r|\n/)) {
|
||||
line = line.replace(/\/\/.*$/, "").trim();
|
||||
if (!line) continue;
|
||||
if (line === "{") {
|
||||
stack.push(pendingBlock);
|
||||
if (pendingBlock === "material") mats.push((mat = { passes: [] }));
|
||||
else if (pendingBlock === "pass" && mat) mat.passes.push((pass = { units: [] }));
|
||||
else if (pendingBlock === "texture_unit" && pass) pass.units.push((unit = { uvSet: 0 }));
|
||||
pendingBlock = null;
|
||||
continue;
|
||||
}
|
||||
if (line === "}") {
|
||||
const top = stack.pop();
|
||||
if (top === "texture_unit") unit = null;
|
||||
else if (top === "pass") pass = null;
|
||||
else if (top === "material") mat = null;
|
||||
continue;
|
||||
}
|
||||
const [key, ...rest] = line.split(/\s+/);
|
||||
if (["material", "technique", "pass", "texture_unit"].includes(key) && !rest.length) {
|
||||
pendingBlock = key;
|
||||
continue;
|
||||
}
|
||||
pendingBlock = key; // unknown block names ("technique x") still nest
|
||||
if (unit) {
|
||||
const wave = () => ({ type: (rest[0] || "").toLowerCase(), wave: (rest[1] || "sine").toLowerCase(), base: Number(rest[2]) || 0, freq: Number(rest[3]) || 0, phase: Number(rest[4]) || 0, amp: Number(rest[5]) || 0, once: rest[6] === "true" });
|
||||
if (key === "texture") unit.texture = rest[0];
|
||||
else if (key === "color_blend") unit.colorBlend = rest.map((x) => x.toLowerCase());
|
||||
else if (key === "tex_coord_set") unit.uvSet = Number(rest[0]) || 0;
|
||||
else if (key === "use_light_texture") unit.light = rest[0] === "true";
|
||||
// texture animation (ioTextureUnitState): frames, scroll, rotate, wave transforms, colour waves
|
||||
else if (key === "anim_texture") unit.frames = { count: Math.max(1, Number(rest[0]) || 1), duration: Number(rest[1]) || 1000, once: rest[2] === "true" };
|
||||
else if (key === "scroll_anim") unit.scroll = { u: Number(rest[0]) || 0, v: Number(rest[1]) || 0, once: rest[2] === "true" };
|
||||
else if (key === "rotate_anim") unit.rotate = { speed: Number(rest[0]) || 0, once: rest[1] === "true" };
|
||||
else if (key === "wave_xform") (unit.waves ||= []).push(wave());
|
||||
else if (key === "color_xform") (unit.colorWaves ||= []).push(wave());
|
||||
else if (key === "scale_uv_on_center") unit.scaleCenter = rest[0] === "true";
|
||||
} else if (pass) {
|
||||
if (key === "shader_group") pass.shader = rest[0];
|
||||
else if (key === "scene_blend") pass.sceneBlend = rest.join(" ");
|
||||
else if (key === "alpha_rejection") pass.alphaReject = rest.join(" ");
|
||||
else if (key === "cull") pass.cull = rest[0];
|
||||
else if (key === "lighting") pass.lighting = rest[0] !== "false";
|
||||
else if (key === "depth_write") pass.depthWrite = rest[0] !== "false";
|
||||
} else if (mat && key === "diffuse") {
|
||||
mat.diffuse = rest.slice(0, 3).map(Number);
|
||||
}
|
||||
}
|
||||
return mats;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the viewer draws for one material: the first real texture of the first non-outline pass.
|
||||
* LightMap passes have the lightmap first (UV set 0) and the diffuse after it (usually set 1).
|
||||
*/
|
||||
function diffuseOf(mat) {
|
||||
for (const p of mat?.passes || []) {
|
||||
if (/outline/i.test(p.shader || "")) continue;
|
||||
const units = p.units.filter((x) => x.texture && !x.light);
|
||||
if (!units.length) continue;
|
||||
const lightmap = /lightmap/i.test(p.shader || "") && units.length > 1;
|
||||
const u = lightmap ? units[units.length - 1] : units[0];
|
||||
const animOf = (x) => (x && (x.frames || x.scroll || x.rotate || x.waves || x.colorWaves) ? { frames: x.frames || null, scroll: x.scroll || null, rotate: x.rotate || null, waves: x.waves || [], colorWaves: x.colorWaves || [], scaleCenter: !!x.scaleCenter } : null);
|
||||
return {
|
||||
texture: u.texture,
|
||||
uvSet: u.uvSet,
|
||||
anim: animOf(u),
|
||||
lightmap: lightmap ? units[0].texture : null,
|
||||
lightmapSet: lightmap ? units[0].uvSet : 0,
|
||||
lightmapAnim: lightmap ? animOf(units[0]) : null,
|
||||
lmAddDiffuse: lightmap && units[0].colorBlend?.join(" ") === "add texture diffuse",
|
||||
diffuseColor: mat.diffuse || [1, 1, 1],
|
||||
blend: p.sceneBlend || "",
|
||||
alphaReject: p.alphaReject || "",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- model config (#MODEL_CFG) ----------
|
||||
function parseModelCfg(text) {
|
||||
const cfg = { skeleton: "", animations: [], meshes: [] };
|
||||
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim());
|
||||
let list = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (!l || l.startsWith(";") || l.startsWith("//")) continue; // commented-out entries
|
||||
if (l === "#SKELETON") cfg.skeleton = lines[++i] || "";
|
||||
else if (l === "#ANIMATION_LIST") list = cfg.animations;
|
||||
else if (l === "#MESH_LIST") list = cfg.meshes;
|
||||
else if (l === "#ANIMATION_LIST_END" || l === "#MESH_LIST_END") list = null;
|
||||
else if (l === "#MODEL_COLOR") cfg.color = (lines[++i] || "").split(/\s+/).map(Number);
|
||||
else if (l === "#AABOX") cfg.aabb = [(lines[++i] || "").split(/\s+/).map(Number), (lines[++i] || "").split(/\s+/).map(Number)];
|
||||
else if (l === "#COLLISION_MESH") cfg.collision = lines[++i] || "";
|
||||
else if (l === "#OUTLINE") {
|
||||
// file, on/off, colour (r g b), thickness
|
||||
cfg.outline = { file: lines[++i] || "", on: (lines[++i] || "") !== "0", color: (lines[++i] || "").split(/\s+/).map(Number), size: Number(lines[++i]) || 0 };
|
||||
}
|
||||
else if (l.startsWith("#")) list = null;
|
||||
else if (list) list.push(l);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ---------- .cms collision mesh (ioOpcodeManagerImpl::LoadOpcodeShapeMesh) ----------
|
||||
/** "CMS\0", version, vertex count, float3[], face count, uint16[3 * faces]. */
|
||||
function parseCms(bytes) {
|
||||
const r = new Reader(bytes);
|
||||
r.magic("CMS");
|
||||
const c = { version: r.i32() };
|
||||
// 5000 = world / object collision; 5500 / 5700 are character attack / hit box groups
|
||||
if (c.version !== 5000) throw new Error(`Not a collision mesh (version ${c.version}: a character box group)`);
|
||||
const V = r.i32();
|
||||
if (V < 0 || V > 4e6) throw new Error("Bad collision vertex count");
|
||||
c.pos = r.f32s(V * 3);
|
||||
const F = r.i32();
|
||||
if (F < 0 || F > 8e6) throw new Error("Bad collision face count");
|
||||
r.need(F * 6);
|
||||
c.indices = new Uint16Array(bytes.slice(r.o, r.o + F * 6).buffer);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------- .edg outline edges (ioEdgeRender::LoadFileInternal) ----------
|
||||
/** vertices + normals, face normals, edges { a, b (vertices), fa, fb (faces, 0xFFFF = open) }. */
|
||||
function parseEdg(bytes) {
|
||||
const r = new Reader(bytes);
|
||||
r.magic("EDG");
|
||||
const e = { version: r.i32() };
|
||||
const V = r.i32();
|
||||
if (V < 0 || V > 4e6) throw new Error("Bad edge vertex count");
|
||||
e.pos = r.f32s(V * 3);
|
||||
e.normal = r.f32s(V * 3);
|
||||
const F = r.i32();
|
||||
if (F < 0 || F > 8e6) throw new Error("Bad edge face count");
|
||||
e.faceNormals = r.f32s(F * 3);
|
||||
const E = r.i32();
|
||||
if (E < 0 || E > 8e6) throw new Error("Bad edge count");
|
||||
r.need(E * 8);
|
||||
e.edges = new Uint16Array(bytes.slice(r.o, r.o + E * 8).buffer); // a, b, fa, fb per edge
|
||||
return e;
|
||||
}
|
||||
|
||||
// ---------- texture animation at time t (seconds), like ioTexture*Effect + RecalcTextureMatrix ----------
|
||||
const waveValue = (w, t) => {
|
||||
let x = t * w.freq;
|
||||
x = w.once && x >= 1 ? 1 : x - Math.floor(x);
|
||||
let o = 0;
|
||||
if (w.wave === "sine") o = Math.sin(x * Math.PI * 2);
|
||||
else if (w.wave === "triangle") o = x < 0.25 ? x * 4 : x <= 0.75 ? 1 - (x - 0.25) * 4 : (x - 0.75) * 4 - 1;
|
||||
else if (w.wave === "square") o = x <= 0.5 ? 1 : -1;
|
||||
else if (w.wave === "sawtooth") o = x * 2 - 1;
|
||||
else if (w.wave === "inverse_sawtooth") o = 1 - x * 2;
|
||||
return w.base + (o + 1) * 0.5 * w.amp;
|
||||
};
|
||||
const wrap = (v, once) => (once ? Math.max(-1, Math.min(1, v)) : v - Math.trunc(v));
|
||||
|
||||
/** → { m: Float32Array(9) for vec3(uv,1) * m (GLSL), frame, colorMul, emissive } */
|
||||
function texAnimAt(a, t) {
|
||||
let uMod = 0, vMod = 0, rot = 0, uScale = 1, vScale = 1;
|
||||
if (a.scroll) {
|
||||
uMod = wrap(-a.scroll.u * t, a.scroll.once);
|
||||
vMod = wrap(-a.scroll.v * t, a.scroll.once);
|
||||
}
|
||||
if (a.rotate) rot = (-a.rotate.speed * t) % 360;
|
||||
let colorMul = 1, emissive = 0;
|
||||
for (const w of a.waves) {
|
||||
const r = waveValue(w, t);
|
||||
if (w.type === "translate_u") uMod = r;
|
||||
else if (w.type === "translate_v") vMod = r;
|
||||
else if (w.type === "rotate") rot = r * 360;
|
||||
else if (w.type === "scale_u") uScale = r >= 0 ? 1 + r : 1 / -r;
|
||||
else if (w.type === "scale_v") vScale = r >= 0 ? 1 + r : 1 / -r;
|
||||
}
|
||||
for (const w of a.colorWaves) {
|
||||
const r = waveValue(w, t);
|
||||
if (w.type === "diffuse" || w.type === "ambient") colorMul *= w.type === "diffuse" ? r : 0.5 + 0.5 * r; // approximation of the pass colour rates
|
||||
else if (w.type === "emissive") emissive += r;
|
||||
}
|
||||
// D3D row-vector 3×3 (uv·M): scale (optionally on the centre), then translate, then rotate about (0.5, 0.5)
|
||||
const mul = (A, B) => {
|
||||
const o = new Array(9);
|
||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) o[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c];
|
||||
return o;
|
||||
};
|
||||
let X = [1 / uScale, 0, 0, 0, 1 / vScale, 0, 0, 0, 1];
|
||||
if (a.scaleCenter) {
|
||||
X[6] = -0.5 * X[0] + 0.5;
|
||||
X[7] = -0.5 * X[4] + 0.5;
|
||||
}
|
||||
if (uMod || vMod) X = mul([1, 0, 0, 0, 1, 0, uMod, vMod, 1], X);
|
||||
if (rot) {
|
||||
const th = (rot * Math.PI) / 180, c = Math.cos(th), s = Math.sin(th);
|
||||
X = mul([c, s, 0, -s, c, 0, 0.5 + (-0.5 * c - -0.5 * s), 0.5 + (-0.5 * s + -0.5 * c), 1], X);
|
||||
}
|
||||
// GLSL mat3 columns = the D3D matrix's columns, used as vec3(uv, 1) * m
|
||||
const m = new Float32Array([X[0], X[3], X[6], X[1], X[4], X[7], X[2], X[5], X[8]]);
|
||||
let frame = 0;
|
||||
if (a.frames && a.frames.count > 1) {
|
||||
let r = (t * 1000) / a.frames.duration;
|
||||
r = a.frames.once && r >= 1 ? 0.999 : r - Math.floor(r);
|
||||
frame = Math.min(a.frames.count - 1, Math.floor(r * a.frames.count));
|
||||
}
|
||||
return { m, frame, colorMul, emissive };
|
||||
}
|
||||
|
||||
const decode = (bytes) => cp949.decode(bytes);
|
||||
return { texAnimAt, parseMsh, writeMsh, buildMsh, parseCms, parseEdg, parseSkl, parseAni, writeAni, encodeRot, decodeRot, sampleTrack, slerp, parseMaterial, diffuseOf, parseModelCfg, decode, S, MESH_TYPES, FVF, ANI_VERSIONS };
|
||||
})();
|
||||
window.LSF = LSF;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,193 @@
|
||||
"use strict";
|
||||
|
||||
// Loading game meshes into a GL3D.View: the .msh, its material script (Material/<mesh>.txt, one
|
||||
// material per sub-mesh), the textures (decoded here, uploaded per view) and model configs.
|
||||
// Shared by the model viewer and the map editor; each keeps its own kit (one texture cache per
|
||||
// WebGL context).
|
||||
|
||||
const MeshKit = (() => {
|
||||
let seq = 0;
|
||||
|
||||
function create(view, getSrc, options = {}) {
|
||||
const tex = new Map(); // texture name → Promise<{ tex, img, w, h, source } | { error }>
|
||||
|
||||
function texture(name) {
|
||||
const key = name.toLowerCase();
|
||||
if (!tex.has(key)) {
|
||||
tex.set(
|
||||
key,
|
||||
(async () => {
|
||||
const src = getSrc();
|
||||
if (!src) return { error: "no source" };
|
||||
const t = await call("mdl:texture", src, name);
|
||||
if (!t) return { error: "not found" };
|
||||
let img;
|
||||
if (t.ext === "dds") img = TexDecode.dds(t.bytes);
|
||||
else if (t.ext === "tga") img = TexDecode.tga(t.bytes);
|
||||
else img = await createImageBitmap(new Blob([t.bytes]), { premultiplyAlpha: "none" });
|
||||
const prepared = options.prepareTexture ? options.prepareTexture({ img, ext: t.ext, name }) : img;
|
||||
return { tex: view.texture(prepared), img: prepared, w: prepared.width, h: prepared.height, source: t.source, ext: t.ext };
|
||||
})().catch((err) => ({ error: err.message })),
|
||||
);
|
||||
}
|
||||
return tex.get(key);
|
||||
}
|
||||
|
||||
async function readNamed(name, folder, strict = false) {
|
||||
const src = getSrc();
|
||||
if (!src) return null;
|
||||
return call("mdl:read", src, name, folder, strict);
|
||||
}
|
||||
|
||||
/**
|
||||
* scene_blend "<op> <src> <dst>" (D3D style: add / subtract / rev_subtract / min / max with
|
||||
* zero, one, src_color, inv_src_color, src_alpha, inv_src_alpha, dest_alpha, inv_dest_alpha,
|
||||
* dest_color, inv_dest_color) → { op, src, dst }, or null for none / opaque.
|
||||
*/
|
||||
function parseBlend(s) {
|
||||
const t = (s || "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!t.length) return null;
|
||||
// ioMaterialSerializer::ParseSceneBlend delegates named modes to
|
||||
// ioPass::SetSceneBlending. Keep its factors, especially additive alpha.
|
||||
if (t.length === 1) {
|
||||
const named = {
|
||||
no_alpha: null,
|
||||
src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
||||
color_key: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
||||
alpha_blend: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
||||
add: { op: "add", src: "src_alpha", dst: "one" },
|
||||
screen: { op: "add", src: "inv_dest_color", dst: "one" },
|
||||
lighten: { op: "max", src: "one", dst: "one" },
|
||||
darken: { op: "min", src: "one", dst: "one" },
|
||||
rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" },
|
||||
subtract: { op: "subtract", src: "src_alpha", dst: "one" },
|
||||
};
|
||||
if (Object.hasOwn(named, t[0])) return named[t[0]];
|
||||
}
|
||||
if (t[0] === "modulate") return { op: "add", src: "dest_color", dst: "zero" };
|
||||
const [op, src, dst] = t.length >= 3 ? t : ["add", ...t];
|
||||
if (src === "one" && dst === "zero") return null;
|
||||
return { op, src: src.replace(/s$/, ""), dst: (dst || "zero").replace(/colors$/, "color") };
|
||||
}
|
||||
|
||||
/** Texture, UV set, lightmap and blending of a sub-mesh from its material. */
|
||||
function groupLook(d, data) {
|
||||
const set = (n) => (n === 1 && data.uv1 ? 1 : 0);
|
||||
const blendFunc = parseBlend(d?.blend);
|
||||
return {
|
||||
uvSet: set(d?.uvSet),
|
||||
texName: d?.texture || null,
|
||||
lmName: d?.lightmap || null,
|
||||
lmSet: set(d?.lightmapSet),
|
||||
lmAddDiffuse: !!d?.lmAddDiffuse,
|
||||
diffuseColor: d?.diffuseColor || [1, 1, 1],
|
||||
blend: !!blendFunc,
|
||||
blendFunc,
|
||||
texAnim: d?.anim || null,
|
||||
lmAnim: d?.lightmapAnim || null,
|
||||
alphaCut: d?.alphaReject ? 0.5 : 0,
|
||||
tex: null,
|
||||
lmTex: null,
|
||||
texInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the material script, builds the draw groups and puts the mesh in the view. → m */
|
||||
async function loadMesh(hit, name, place = {}) {
|
||||
const data = LSF.parseMsh(hit.bytes);
|
||||
const m = { id: ++seq, name, data, where: hit.where, source: hit.source, visible: true, dirty: false, mat: null, groups: [] };
|
||||
const mh = await readNamed(`${name}.txt`, "material", true).catch(() => null);
|
||||
if (mh) {
|
||||
const text = LSF.decode(mh.bytes);
|
||||
m.mat = { text, raw: mh.bytes, where: mh.where, source: mh.source, mats: LSF.parseMaterial(text), dirty: false };
|
||||
}
|
||||
const subs = data.subs.length ? data.subs : [{ indexStart: 0, faceCount: data.indices.length / 3 }];
|
||||
m.groups = subs.map((sub, i) => {
|
||||
const start = Math.min(sub.indexStart, data.indices.length);
|
||||
return Object.assign({ start, count: Math.min(sub.faceCount * 3, data.indices.length - start) }, groupLook(LSF.diffuseOf(m.mat?.mats[i]), data));
|
||||
});
|
||||
if (data.boneNames?.length) {
|
||||
m.skinPos = new Float32Array(data.pos.length);
|
||||
m.skinNrm = data.normal ? new Float32Array(data.normal.length) : null;
|
||||
}
|
||||
view.addMesh(m.id, { pos: data.pos, normal: data.normal, uv0: data.uv0, uv1: data.uv1, indices: data.indices, groups: m.groups }, place);
|
||||
return m;
|
||||
}
|
||||
|
||||
async function loadTextures(m) {
|
||||
await Promise.all(
|
||||
m.groups.map(async (g, gi) => {
|
||||
if (g.texName) {
|
||||
const t = await texture(g.texName);
|
||||
g.texInfo = t;
|
||||
if (t.tex) view.setGroupTexture(m.id, gi, t.tex);
|
||||
// anim_texture N: frames <name>1.dds … <name>(N-1).dds after the base one
|
||||
const n = g.texAnim?.frames?.count || 0;
|
||||
if (n > 1) {
|
||||
const dot = g.texName.lastIndexOf(".");
|
||||
const stem = dot > 0 ? g.texName.slice(0, dot) : g.texName;
|
||||
const ext = dot > 0 ? g.texName.slice(dot) : "";
|
||||
const frames = [t.tex || null];
|
||||
for (let i = 1; i < n; i++) frames.push((await texture(`${stem}${i}${ext}`)).tex || null);
|
||||
g.frameTex = frames;
|
||||
}
|
||||
}
|
||||
if (g.lmName) {
|
||||
const t = await texture(g.lmName);
|
||||
if (t.tex) view.setGroupTexture(m.id, gi, t.tex, "lmTex");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-reads a mesh's draw groups after its material text changed. */
|
||||
function applyMaterial(m) {
|
||||
m.groups.forEach((g, i) => {
|
||||
Object.assign(g, groupLook(LSF.diffuseOf(m.mat.mats[i]), m.data));
|
||||
view.setGroupTexture(m.id, i, null);
|
||||
view.setGroupTexture(m.id, i, null, "lmTex");
|
||||
});
|
||||
return loadTextures(m);
|
||||
}
|
||||
|
||||
/** A model config (resource/model/<path>) by its path, e.g. "staticmodel/prisoner.txt". */
|
||||
async function readModel(path) {
|
||||
const src = getSrc();
|
||||
if (!src) return null;
|
||||
const hit = await call("mdl:readPath", src, `model/${path.replace(/\\/g, "/").replace(/^\/+/, "")}`);
|
||||
return hit ? { hit, cfg: LSF.parseModelCfg(LSF.decode(hit.bytes)) } : null;
|
||||
}
|
||||
|
||||
/** Texture animations of a mesh at time t (s); t = null puts them back to rest. → true when something animates */
|
||||
function animate(m, t) {
|
||||
let any = false;
|
||||
for (const g of m.groups) {
|
||||
if (g.texAnim) {
|
||||
any = true;
|
||||
if (t === null) {
|
||||
g.texM = null;
|
||||
g.colorMul = 1;
|
||||
g.emissive = 0;
|
||||
if (g.frameTex?.[0]) g.tex = g.frameTex[0];
|
||||
} else {
|
||||
const r = LSF.texAnimAt(g.texAnim, t);
|
||||
g.texM = r.m;
|
||||
g.colorMul = r.colorMul;
|
||||
g.emissive = r.emissive;
|
||||
if (g.frameTex?.[r.frame]) g.tex = g.frameTex[r.frame];
|
||||
}
|
||||
}
|
||||
if (g.lmAnim) {
|
||||
any = true;
|
||||
g.lmM = t === null ? null : LSF.texAnimAt(g.lmAnim, t).m;
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
return { texture, readNamed, groupLook, loadMesh, loadTextures, applyMaterial, readModel, animate, clearTextures: () => tex.clear() };
|
||||
}
|
||||
|
||||
return { create };
|
||||
})();
|
||||
window.MeshKit = MeshKit;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
"use strict";
|
||||
|
||||
// Skeleton poses and CPU skinning, the io3DEngine way (D3D row vectors: v' = v · BipedSpaceTM ·
|
||||
// BoneWorld; keys replace the bone's local position / rotation; linked skeletons use the offset TM
|
||||
// and bone-length ratio of the skeleton they link to). Used by the map editor for animated map
|
||||
// objects; the model viewer (model.js) has the same maths inline.
|
||||
|
||||
const SkinKit = (() => {
|
||||
const M4 = {
|
||||
fromQT(q, p) {
|
||||
const [x, y, z, w] = q;
|
||||
return new Float64Array([
|
||||
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
|
||||
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
|
||||
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
|
||||
p[0], p[1], p[2], 1,
|
||||
]);
|
||||
},
|
||||
mul(a, b) {
|
||||
const o = new Float64Array(16);
|
||||
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) o[r * 4 + c] = a[r * 4] * b[c] + a[r * 4 + 1] * b[4 + c] + a[r * 4 + 2] * b[8 + c] + a[r * 4 + 3] * b[12 + c];
|
||||
return o;
|
||||
},
|
||||
/** Inverse of an affine row-vector matrix (rotation / scale part + translation row). */
|
||||
invertAffine(m) {
|
||||
const a = m[0], b = m[1], c = m[2], d = m[4], e = m[5], f = m[6], g = m[8], h = m[9], i = m[10];
|
||||
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||
if (Math.abs(det) < 1e-12) return M4.fromQT([0, 0, 0, 1], [0, 0, 0]);
|
||||
const k = 1 / det;
|
||||
const r = new Float64Array(16);
|
||||
r[0] = (e * i - f * h) * k; r[1] = (c * h - b * i) * k; r[2] = (b * f - c * e) * k;
|
||||
r[4] = (f * g - d * i) * k; r[5] = (a * i - c * g) * k; r[6] = (c * d - a * f) * k;
|
||||
r[8] = (d * h - e * g) * k; r[9] = (b * g - a * h) * k; r[10] = (a * e - b * d) * k;
|
||||
const tx = m[12], ty = m[13], tz = m[14];
|
||||
r[12] = -(tx * r[0] + ty * r[4] + tz * r[8]);
|
||||
r[13] = -(tx * r[1] + ty * r[5] + tz * r[9]);
|
||||
r[14] = -(tx * r[2] + ty * r[6] + tz * r[10]);
|
||||
r[15] = 1;
|
||||
return r;
|
||||
},
|
||||
};
|
||||
const qmul = (a, b) => [
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
];
|
||||
function qrot(q, v) {
|
||||
const [x, y, z, w] = q;
|
||||
const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0];
|
||||
const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux;
|
||||
return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)];
|
||||
}
|
||||
|
||||
/** A parsed .skl (LSF.parseSkl) → skeleton ready for posing. */
|
||||
function prepare(data) {
|
||||
const n = data.bones.length;
|
||||
const byName = new Map();
|
||||
data.bones.forEach((b, i) => byName.has(b.name.text.toLowerCase()) || byName.set(b.name.text.toLowerCase(), i));
|
||||
const parent = data.bones.map((b) => {
|
||||
const p = b.parent.text.toLowerCase();
|
||||
return p === "noparent" || !byName.has(p) ? -1 : byName.get(p);
|
||||
});
|
||||
const order = [];
|
||||
const done = new Uint8Array(n);
|
||||
const visit = (i, depth) => {
|
||||
if (done[i] || depth > n) return;
|
||||
if (parent[i] >= 0) visit(parent[i], depth + 1);
|
||||
if (!done[i]) {
|
||||
done[i] = 1;
|
||||
order.push(i);
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < n; i++) visit(i, 0);
|
||||
return { data, byName, parent, order, bst: data.bones.map((b) => M4.fromQT(b.invRot, b.invPos)), rate: new Float64Array(n).fill(1), linked: data.linked || "" };
|
||||
}
|
||||
|
||||
/** Applies the skeleton it links to (same bone count required, like the client). */
|
||||
function link(sk, other) {
|
||||
if (!other || other.bones.length !== sk.data.bones.length) return false;
|
||||
const rm = (a) => Float64Array.from(a);
|
||||
sk.data.bones.forEach((b, i) => {
|
||||
const o = other.bones[i];
|
||||
const offset = M4.mul(M4.invertAffine(rm(o.objectTM)), rm(b.objectTM));
|
||||
sk.bst[i] = M4.mul(offset, M4.fromQT(b.invRot, b.invPos));
|
||||
const l = Math.hypot(...o.localPos);
|
||||
sk.rate[i] = l > 1e-6 ? Math.hypot(...b.localPos) / l : 1;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const mapTracks = (sk, ani) => Int32Array.from(ani.tracks, (t) => sk.byName.get(t.name.text.toLowerCase()) ?? -1);
|
||||
const palette = (sk, meshData) => (meshData.boneNames ? Int32Array.from(meshData.boneNames, (b) => sk.byName.get(b.text.toLowerCase()) ?? -1) : null);
|
||||
|
||||
/** Bone world transforms + skin matrices at t (ms). */
|
||||
function pose(sk, ani, map, t) {
|
||||
const bones = sk.data.bones;
|
||||
const n = bones.length;
|
||||
const lp = bones.map((b) => b.localPos.slice());
|
||||
const lr = bones.map((b) => b.localRot.slice());
|
||||
if (ani) {
|
||||
const p = [0, 0, 0];
|
||||
const q = [0, 0, 0, 1];
|
||||
ani.tracks.forEach((tr, ti) => {
|
||||
const bi = map[ti];
|
||||
if (bi < 0 || tr.weight <= 0 || !LSF.sampleTrack(tr, t, p, q)) return;
|
||||
const r = sk.rate[bi];
|
||||
lp[bi] = [p[0] * r, p[1] * r, p[2] * r];
|
||||
lr[bi] = q.slice();
|
||||
});
|
||||
}
|
||||
const wp = new Array(n);
|
||||
const wr = new Array(n);
|
||||
for (const i of sk.order) {
|
||||
const pi = sk.parent[i];
|
||||
if (pi < 0) {
|
||||
wr[i] = lr[i];
|
||||
wp[i] = lp[i];
|
||||
} else {
|
||||
wr[i] = qmul(wr[pi], lr[i]);
|
||||
const v = qrot(wr[pi], lp[i]);
|
||||
wp[i] = [v[0] + wp[pi][0], v[1] + wp[pi][1], v[2] + wp[pi][2]];
|
||||
}
|
||||
}
|
||||
const skin = new Array(n);
|
||||
for (let i = 0; i < n; i++) skin[i] = M4.mul(sk.bst[i], M4.fromQT(wr[i] || [0, 0, 0, 1], wp[i] || [0, 0, 0]));
|
||||
return { wp, wr, skin };
|
||||
}
|
||||
|
||||
/** Skins a meshkit mesh (m.data, m.skinPos / m.skinNrm) with a palette into its buffers. */
|
||||
function skin(m, pal, ps) {
|
||||
const d = m.data;
|
||||
const V = d.vertexCount;
|
||||
const P = d.pos;
|
||||
const N = d.normal;
|
||||
const out = m.skinPos;
|
||||
const on = m.skinNrm;
|
||||
for (let v = 0; v < V; v++) {
|
||||
let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0;
|
||||
const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2];
|
||||
const qx = N ? N[v * 3] : 0, qy = N ? N[v * 3 + 1] : 0, qz = N ? N[v * 3 + 2] : 0;
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const w = d.weights[v * 4 + j];
|
||||
if (w <= 0) continue;
|
||||
const bi = pal[d.boneIdx[v * 4 + j]];
|
||||
if (bi === undefined || bi < 0) continue;
|
||||
const M = ps.skin[bi];
|
||||
x += w * (px * M[0] + py * M[4] + pz * M[8] + M[12]);
|
||||
y += w * (px * M[1] + py * M[5] + pz * M[9] + M[13]);
|
||||
z += w * (px * M[2] + py * M[6] + pz * M[10] + M[14]);
|
||||
if (N) {
|
||||
nx += w * (qx * M[0] + qy * M[4] + qz * M[8]);
|
||||
ny += w * (qx * M[1] + qy * M[5] + qz * M[9]);
|
||||
nz += w * (qx * M[2] + qy * M[6] + qz * M[10]);
|
||||
}
|
||||
tw += w;
|
||||
}
|
||||
if (tw <= 0) {
|
||||
x = px; y = py; z = pz; nx = qx; ny = qy; nz = qz;
|
||||
} else if (Math.abs(tw - 1) > 1e-3) {
|
||||
x /= tw; y /= tw; z /= tw;
|
||||
}
|
||||
out[v * 3] = x;
|
||||
out[v * 3 + 1] = y;
|
||||
out[v * 3 + 2] = z;
|
||||
if (on) {
|
||||
on[v * 3] = nx;
|
||||
on[v * 3 + 1] = ny;
|
||||
on[v * 3 + 2] = nz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { M4, prepare, link, mapTracks, palette, pose, skin };
|
||||
})();
|
||||
window.SkinKit = SkinKit;
|
||||
@@ -0,0 +1,768 @@
|
||||
"use strict";
|
||||
|
||||
// Tables tab (DAT Editor): the client's .dat tables (resource/tables in the packs) and the server's
|
||||
// (ls_gamesvr\config\tables) as a spreadsheet, read and written by src/main/dattable.js. The columns
|
||||
// come from the file and can't change (the game checks the header version); rows and values can.
|
||||
// Grid: click / drag / shift to select, type or F2 / double-click to edit, Enter / Tab to move,
|
||||
// Ctrl+C / Ctrl+V with Excel (tab-separated), Delete clears, Ctrl+Z / Ctrl+Y, Ctrl+S saves.
|
||||
|
||||
(() => {
|
||||
const T = { lists: { client: [], server: [] }, cur: null, meta: null, busy: false };
|
||||
window.DT = T; // (dev probes)
|
||||
const LS = (k, v) => {
|
||||
try {
|
||||
if (v === undefined) return localStorage.getItem(k) || "";
|
||||
localStorage.setItem(k, v);
|
||||
} catch {}
|
||||
return "";
|
||||
};
|
||||
const RH = 26;
|
||||
|
||||
// ---------- table list ----------
|
||||
const clientSrc = () => T.src || profileSource();
|
||||
|
||||
async function listTables() {
|
||||
const src = clientSrc();
|
||||
const server = LS("dt:server");
|
||||
try {
|
||||
T.lists.client = src ? await call("dat:client", src) : [];
|
||||
} catch (err) {
|
||||
T.lists.client = [];
|
||||
toast(err.message, true);
|
||||
}
|
||||
T.lists.server = server ? await call("dat:folder", server) : [];
|
||||
$("dt-source").textContent = [src ? `client: ${src.folder}` : "no game folder in this profile", server ? `server: ${server}` : "no server folder"].join(" · ");
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const group = (title, items, kind, empty) => [
|
||||
el("div", { class: "dt-group", text: title }),
|
||||
...(items.length
|
||||
? items.map((it) =>
|
||||
el(
|
||||
"button",
|
||||
{
|
||||
class: `dt-item${T.cur && T.cur.refKey === `${kind}|${it.key}` ? " on" : ""}`,
|
||||
title: it.path,
|
||||
onclick: () => openTable(kind === "client" ? { kind: "pack", src: clientSrc(), key: it.key } : { kind: "file", file: it.path }, `${kind}|${it.key}`),
|
||||
},
|
||||
[el("span", { text: it.name.replace(/\.dat$/i, "") }), kind === "client" && it.pack ? el("small", { class: "muted", text: it.pack }) : null],
|
||||
),
|
||||
)
|
||||
: [el("div", { class: "dt-none muted", text: empty })]),
|
||||
];
|
||||
const opened = T.cur && T.cur.ref.kind === "file" && !T.lists.server.some((x) => x.path === T.cur.ref.file) ? group("File", [{ key: T.cur.ref.file, name: T.cur.ref.file.split(/[\\/]/).pop(), path: T.cur.ref.file }], "file", "") : [];
|
||||
$("dt-list").replaceChildren(...group("Client (packs)", T.lists.client, "client", "No resource/tables/*.dat in the packs."), ...group("Server", T.lists.server, "server", "Pick the server's tables folder."), ...opened);
|
||||
}
|
||||
|
||||
// ---------- opening ----------
|
||||
async function leave() {
|
||||
if (!T.cur?.dirty()) return true;
|
||||
return confirmBox("Unsaved changes", `${T.cur.name} has changes that aren't saved. Drop them?`, "Drop changes", "danger");
|
||||
}
|
||||
|
||||
async function openTable(ref, refKey, { encoding = "auto" } = {}) {
|
||||
if (T.cur && T.cur.refKey !== refKey && !(await leave())) return;
|
||||
let d;
|
||||
try {
|
||||
d = await call("dat:open", ref, encoding);
|
||||
} catch (err) {
|
||||
return toast(err.message, true);
|
||||
}
|
||||
if (T.cur) call("dat:close", T.cur.id);
|
||||
const folder = LS("dt:maker");
|
||||
let meta = null;
|
||||
try {
|
||||
meta = folder ? await call("dat:meta", folder, d.name) : null;
|
||||
} catch {}
|
||||
T.cur = new Sheet(d, ref, refKey, meta);
|
||||
$("dt-enc").value = d.encoding;
|
||||
$("dt-search").value = "";
|
||||
renderList();
|
||||
T.cur.mount($("dt-grid"));
|
||||
status();
|
||||
}
|
||||
|
||||
// ---------- the sheet ----------
|
||||
class Sheet {
|
||||
constructor(d, ref, refKey, meta) {
|
||||
this.id = d.id;
|
||||
this.ref = ref;
|
||||
this.refKey = refKey;
|
||||
this.name = d.name;
|
||||
this.info = d;
|
||||
this.meta = meta;
|
||||
this.cols = d.cols.map((c) => ({ ...c, meta: meta?.cols?.[c.name] || null, enums: meta?.enums?.[c.name] || null }));
|
||||
this.setData(d.rows);
|
||||
this.undo = [];
|
||||
this.redo = [];
|
||||
this.sort = null;
|
||||
this.filter = "";
|
||||
this.act = { r: 0, c: 0 };
|
||||
this.anchor = { r: 0, c: 0 };
|
||||
this.widths = this.cols.map((c, i) => this.guessWidth(c, i));
|
||||
}
|
||||
|
||||
setData(rows) {
|
||||
this.orig = rows;
|
||||
this.rows = rows.map((v, o) => ({ o, v: [...v] }));
|
||||
this.view = this.rows.map((_, i) => i);
|
||||
}
|
||||
|
||||
guessWidth(c, i) {
|
||||
let w = c.name.length * 7.5 + 26;
|
||||
for (const r of this.rows.slice(0, 200)) w = Math.max(w, String(r.v[i]).length * 7 + 18);
|
||||
return Math.round(Math.min(Math.max(w, 56), c.text ? 320 : 150));
|
||||
}
|
||||
|
||||
dirty() {
|
||||
if (this.rows.length !== this.orig.length) return true;
|
||||
return this.rows.some((r, i) => r.o !== i || r.v.some((v, c) => v !== this.orig[i][c]));
|
||||
}
|
||||
|
||||
cellDirty(row, c) {
|
||||
return row.o < 0 || row.v[c] !== this.orig[row.o]?.[c];
|
||||
}
|
||||
|
||||
// ----- view -----
|
||||
applyView() {
|
||||
const q = this.filter.toLowerCase();
|
||||
let idx = this.rows.map((_, i) => i);
|
||||
if (q) idx = idx.filter((i) => this.rows[i].v.some((v) => String(v).toLowerCase().includes(q)));
|
||||
if (this.sort) {
|
||||
const { c, desc } = this.sort;
|
||||
const num = !this.cols[c].text;
|
||||
idx.sort((a, b) => {
|
||||
const x = this.rows[a].v[c];
|
||||
const y = this.rows[b].v[c];
|
||||
const r = num ? Number(x) - Number(y) : String(x).localeCompare(String(y));
|
||||
return (desc ? -r : r) || a - b;
|
||||
});
|
||||
}
|
||||
this.view = idx;
|
||||
this.act.r = Math.min(this.act.r, Math.max(0, idx.length - 1));
|
||||
this.anchor = { ...this.act };
|
||||
}
|
||||
|
||||
// ----- DOM -----
|
||||
mount(host) {
|
||||
this.host = host;
|
||||
host.replaceChildren();
|
||||
host.tabIndex = 0;
|
||||
this.head = el("div", { class: "dt-head" });
|
||||
this.body = el("div", { class: "dt-rows" });
|
||||
host.append(this.head, this.body);
|
||||
host.onscroll = () => this.draw();
|
||||
host.onkeydown = (e) => this.key(e);
|
||||
host.onmousedown = (e) => this.mouse(e);
|
||||
host.ondblclick = (e) => {
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (cell) this.edit();
|
||||
};
|
||||
host.oncopy = (e) => this.copy(e);
|
||||
host.onpaste = (e) => this.paste(e);
|
||||
this.drawHead();
|
||||
this.layout();
|
||||
host.focus();
|
||||
}
|
||||
|
||||
colLeft(c) {
|
||||
let x = 56;
|
||||
for (let i = 0; i < c; i++) x += this.widths[i];
|
||||
return x;
|
||||
}
|
||||
|
||||
layout() {
|
||||
const total = this.colLeft(this.cols.length);
|
||||
this.head.style.width = `${total}px`;
|
||||
this.body.style.width = `${total}px`;
|
||||
this.body.style.height = `${this.view.length * RH}px`;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
drawHead() {
|
||||
const cells = [el("div", { class: "dt-h dt-rn", text: "#", style: "width:56px" })];
|
||||
this.cols.forEach((c, i) => {
|
||||
const tip = [`${c.name} — ${c.typeName}`, c.meta?.desc, c.meta?.export ? `used by: ${c.meta.export}` : null, c.enums ? `enum: ${c.enums.map((e) => `${e.value} ${e.name}`).join(", ")}` : null].filter(Boolean).join("\n");
|
||||
const arrow = this.sort?.c === i ? (this.sort.desc ? " ▼" : " ▲") : "";
|
||||
const h = el("div", { class: "dt-h", title: tip, style: `width:${this.widths[i]}px` }, [
|
||||
el("b", { text: c.name + arrow }),
|
||||
el("small", { text: c.meta?.desc ? `${c.typeName} · ${c.meta.desc}` : c.typeName }),
|
||||
el("i", { class: "dt-resize", onmousedown: (e) => this.resize(e, i) }),
|
||||
]);
|
||||
h.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("dt-resize")) return;
|
||||
this.sort = this.sort?.c !== i ? { c: i, desc: false } : this.sort.desc ? null : { c: i, desc: true };
|
||||
this.applyView();
|
||||
this.drawHead();
|
||||
this.layout();
|
||||
status();
|
||||
});
|
||||
cells.push(h);
|
||||
});
|
||||
this.head.replaceChildren(...cells);
|
||||
}
|
||||
|
||||
resize(e, i) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const x0 = e.clientX;
|
||||
const w0 = this.widths[i];
|
||||
const move = (ev) => {
|
||||
this.widths[i] = Math.max(40, w0 + ev.clientX - x0);
|
||||
this.drawHead();
|
||||
this.layout();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
sel() {
|
||||
return { r0: Math.min(this.act.r, this.anchor.r), r1: Math.max(this.act.r, this.anchor.r), c0: Math.min(this.act.c, this.anchor.c), c1: Math.max(this.act.c, this.anchor.c) };
|
||||
}
|
||||
|
||||
display(c, v) {
|
||||
if (c.enums && v !== "") {
|
||||
const e = c.enums.find((x) => String(x.value) === String(v));
|
||||
if (e) return [el("span", { text: v }), el("small", { class: "dt-enum", text: e.name })];
|
||||
}
|
||||
return [document.createTextNode(v)];
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.host) return;
|
||||
const top = this.host.scrollTop - 34;
|
||||
const h = this.host.clientHeight;
|
||||
const first = Math.max(0, Math.floor(top / RH) - 4);
|
||||
const last = Math.min(this.view.length, Math.ceil((top + h) / RH) + 4);
|
||||
const s = this.sel();
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let vr = first; vr < last; vr++) {
|
||||
const row = this.rows[this.view[vr]];
|
||||
const line = el("div", { class: `dt-row${row.o < 0 ? " new" : ""}`, style: `top:${vr * RH}px` });
|
||||
line.append(el("div", { class: "dt-cell dt-rn", text: String(this.view[vr] + 1), style: "width:56px", "data-r": vr, "data-c": -1 }));
|
||||
this.cols.forEach((c, ci) => {
|
||||
const inSel = vr >= s.r0 && vr <= s.r1 && ci >= s.c0 && ci <= s.c1;
|
||||
const cls = ["dt-cell", c.text ? "txt" : "num", this.cellDirty(row, ci) ? "dirty" : "", inSel ? "sel" : "", vr === this.act.r && ci === this.act.c ? "act" : ""].filter(Boolean).join(" ");
|
||||
line.append(el("div", { class: cls, style: `width:${this.widths[ci]}px`, "data-r": vr, "data-c": ci }, this.display(c, row.v[ci])));
|
||||
});
|
||||
frag.append(line);
|
||||
}
|
||||
this.body.replaceChildren(frag);
|
||||
}
|
||||
|
||||
scrollTo(vr, c) {
|
||||
const y = vr * RH;
|
||||
const hh = 34;
|
||||
if (y < this.host.scrollTop) this.host.scrollTop = y;
|
||||
else if (y + RH > this.host.scrollTop + this.host.clientHeight - hh) this.host.scrollTop = y + RH - this.host.clientHeight + hh;
|
||||
if (c >= 0) {
|
||||
const x = this.colLeft(c);
|
||||
const w = this.widths[c];
|
||||
if (x - 56 < this.host.scrollLeft) this.host.scrollLeft = x - 56;
|
||||
else if (x + w > this.host.scrollLeft + this.host.clientWidth) this.host.scrollLeft = x + w - this.host.clientWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- selection -----
|
||||
cellAt(e) {
|
||||
const cell = e.target.closest?.(".dt-cell");
|
||||
if (!cell) return null;
|
||||
return { r: Number(cell.dataset.r), c: Number(cell.dataset.c) };
|
||||
}
|
||||
|
||||
mouse(e) {
|
||||
if (e.button !== 0 || e.target.closest(".dt-h") || e.target.closest(".dt-editor")) return;
|
||||
const at = this.cellAt(e);
|
||||
if (!at) return;
|
||||
this.commitEdit();
|
||||
if (at.c < 0) {
|
||||
// row number: whole rows
|
||||
if (!e.shiftKey) this.anchor = { r: at.r, c: 0 };
|
||||
this.act = { r: at.r, c: this.cols.length - 1 };
|
||||
this.anchor.c = 0;
|
||||
} else {
|
||||
this.act = at;
|
||||
if (!e.shiftKey) this.anchor = { ...at };
|
||||
}
|
||||
this.draw();
|
||||
status();
|
||||
const move = (ev) => {
|
||||
const t = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.(".dt-cell");
|
||||
if (!t || !this.host.contains(t)) return;
|
||||
const r = Number(t.dataset.r);
|
||||
const c = Number(t.dataset.c);
|
||||
if (r === this.act.r && (c === this.act.c || c < 0)) return;
|
||||
this.act = { r, c: c < 0 ? this.act.c : c };
|
||||
this.draw();
|
||||
status();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
move(dr, dc, extend) {
|
||||
this.act = { r: Math.max(0, Math.min(this.view.length - 1, this.act.r + dr)), c: Math.max(0, Math.min(this.cols.length - 1, this.act.c + dc)) };
|
||||
if (!extend) this.anchor = { ...this.act };
|
||||
this.scrollTo(this.act.r, this.act.c);
|
||||
this.draw();
|
||||
status();
|
||||
}
|
||||
|
||||
key(e) {
|
||||
if (this.editor) return;
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const k = e.key;
|
||||
const page = Math.max(1, Math.floor(this.host.clientHeight / RH) - 2);
|
||||
if (k === "ArrowDown") this.move(ctrl ? this.view.length : 1, 0, e.shiftKey);
|
||||
else if (k === "ArrowUp") this.move(ctrl ? -this.view.length : -1, 0, e.shiftKey);
|
||||
else if (k === "ArrowRight") this.move(0, ctrl ? this.cols.length : 1, e.shiftKey);
|
||||
else if (k === "ArrowLeft") this.move(0, ctrl ? -this.cols.length : -1, e.shiftKey);
|
||||
else if (k === "PageDown") this.move(page, 0, e.shiftKey);
|
||||
else if (k === "PageUp") this.move(-page, 0, e.shiftKey);
|
||||
else if (k === "Home") this.move(ctrl ? -this.view.length : 0, -this.cols.length, e.shiftKey);
|
||||
else if (k === "End") this.move(ctrl ? this.view.length : 0, this.cols.length, e.shiftKey);
|
||||
else if (k === "Tab") this.move(0, e.shiftKey ? -1 : 1, false);
|
||||
else if (k === "Enter" && ctrl) addRow();
|
||||
else if (k === "Enter" || k === "F2") this.edit();
|
||||
else if (k === "Delete" && ctrl) deleteRows();
|
||||
else if (k === "Delete" || k === "Backspace") this.clear();
|
||||
else if (ctrl && k.toLowerCase() === "a") {
|
||||
this.anchor = { r: 0, c: 0 };
|
||||
this.act = { r: this.view.length - 1, c: this.cols.length - 1 };
|
||||
this.draw();
|
||||
status();
|
||||
} else if (ctrl && k.toLowerCase() === "z") this.doUndo();
|
||||
else if (ctrl && k.toLowerCase() === "y") this.doRedo();
|
||||
else if (ctrl && k.toLowerCase() === "s") save();
|
||||
else if (ctrl && k.toLowerCase() === "d") duplicateRows();
|
||||
else if (ctrl && k.toLowerCase() === "f") $("dt-search").focus();
|
||||
else if (!ctrl && !e.altKey && k.length === 1) {
|
||||
this.edit(k);
|
||||
e.preventDefault();
|
||||
return;
|
||||
} else return;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ----- editing -----
|
||||
edit(start) {
|
||||
if (!this.view.length) return;
|
||||
const vr = this.act.r;
|
||||
const c = this.act.c;
|
||||
const row = this.rows[this.view[vr]];
|
||||
this.scrollTo(vr, c);
|
||||
const col = this.cols[c];
|
||||
const input = el("input", { class: "dt-editor", type: "text", spellcheck: "false" });
|
||||
input.value = start ?? row.v[c];
|
||||
input.style.left = `${this.colLeft(c)}px`;
|
||||
input.style.top = `${vr * RH}px`;
|
||||
input.style.width = `${Math.max(this.widths[c], col.text ? 260 : 120)}px`;
|
||||
if (col.enums) input.setAttribute("list", this.enumList(col));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.commitEdit();
|
||||
this.move(e.shiftKey ? -1 : 1, 0, false);
|
||||
this.host.focus();
|
||||
} else if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
this.commitEdit();
|
||||
this.move(0, e.shiftKey ? -1 : 1, false);
|
||||
this.host.focus();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
this.cancelEdit();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
input.addEventListener("blur", () => this.commitEdit());
|
||||
this.body.append(input);
|
||||
this.editor = { input, row, c };
|
||||
input.focus();
|
||||
if (start === undefined) input.select();
|
||||
}
|
||||
|
||||
enumList(col) {
|
||||
const id = `dt-enum-${col.name}`;
|
||||
if (!document.getElementById(id)) document.body.append(el("datalist", { id }, col.enums.map((e) => el("option", { value: String(e.value), label: e.name }))));
|
||||
return id;
|
||||
}
|
||||
|
||||
commitEdit() {
|
||||
const ed = this.editor;
|
||||
if (!ed) return;
|
||||
this.editor = null;
|
||||
let v = ed.input.value;
|
||||
const col = this.cols[ed.c];
|
||||
// an enum can be typed by its name
|
||||
if (col.enums && !/^-?\d+$/.test(v.trim())) {
|
||||
const e = col.enums.find((x) => x.name.toLowerCase() === v.trim().toLowerCase());
|
||||
if (e) v = String(e.value);
|
||||
}
|
||||
if (!col.text) v = v.trim();
|
||||
ed.input.remove();
|
||||
if (v !== ed.row.v[ed.c]) this.change([{ row: ed.row, c: ed.c, v }]);
|
||||
else this.draw();
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
const ed = this.editor;
|
||||
if (!ed) return;
|
||||
this.editor = null;
|
||||
ed.input.remove();
|
||||
this.host.focus();
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/** cell changes [{ row, c, v }] as one undo step */
|
||||
change(list) {
|
||||
const step = { cells: list.map((x) => ({ row: x.row, c: x.c, old: x.row.v[x.c], v: x.v })) };
|
||||
for (const x of step.cells) x.row.v[x.c] = x.v;
|
||||
this.undo.push(step);
|
||||
this.redo = [];
|
||||
this.draw();
|
||||
status();
|
||||
}
|
||||
|
||||
/** a row operation: the rows array before and after */
|
||||
rowsStep(after) {
|
||||
this.undo.push({ before: this.rows, after });
|
||||
this.redo = [];
|
||||
this.rows = after;
|
||||
this.applyView();
|
||||
this.layout();
|
||||
status();
|
||||
}
|
||||
|
||||
doUndo() {
|
||||
const s = this.undo.pop();
|
||||
if (!s) return;
|
||||
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.old;
|
||||
else {
|
||||
this.rows = s.before;
|
||||
this.applyView();
|
||||
this.layout();
|
||||
}
|
||||
this.redo.push(s);
|
||||
this.draw();
|
||||
status();
|
||||
}
|
||||
|
||||
doRedo() {
|
||||
const s = this.redo.pop();
|
||||
if (!s) return;
|
||||
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.v;
|
||||
else {
|
||||
this.rows = s.after;
|
||||
this.applyView();
|
||||
this.layout();
|
||||
}
|
||||
this.undo.push(s);
|
||||
this.draw();
|
||||
status();
|
||||
}
|
||||
|
||||
clear() {
|
||||
const s = this.sel();
|
||||
const list = [];
|
||||
for (let vr = s.r0; vr <= s.r1; vr++) for (let c = s.c0; c <= s.c1; c++) list.push({ row: this.rows[this.view[vr]], c, v: this.cols[c].text ? "" : "0" });
|
||||
this.change(list);
|
||||
}
|
||||
|
||||
// ----- clipboard (tab-separated, like Excel) -----
|
||||
copy(e) {
|
||||
if (this.editor) return;
|
||||
const s = this.sel();
|
||||
const lines = [];
|
||||
for (let vr = s.r0; vr <= s.r1; vr++) {
|
||||
const row = this.rows[this.view[vr]];
|
||||
lines.push(row.v.slice(s.c0, s.c1 + 1).map((v) => (/[\t\n"]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v)).join("\t"));
|
||||
}
|
||||
e.clipboardData.setData("text/plain", lines.join("\r\n"));
|
||||
e.preventDefault();
|
||||
toast(`Copied ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} cells.`);
|
||||
}
|
||||
|
||||
paste(e) {
|
||||
if (this.editor) return;
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
if (!text) return;
|
||||
const grid = parseTsv(text);
|
||||
if (!grid.length) return;
|
||||
const s = this.sel();
|
||||
// one value into a bigger selection fills it; otherwise the block starts at the top-left cell
|
||||
const fill = grid.length === 1 && grid[0].length === 1 && (s.r1 > s.r0 || s.c1 > s.c0);
|
||||
const list = [];
|
||||
let after = null;
|
||||
const need = fill ? 0 : s.r0 + grid.length - this.view.length;
|
||||
if (need > 0) {
|
||||
// more rows than there are below: new rows at the end
|
||||
after = [...this.rows];
|
||||
for (let i = 0; i < need; i++) after.push({ o: -1, v: this.cols.map((c) => (c.text ? "" : "0")) });
|
||||
this.rows = after;
|
||||
this.applyView();
|
||||
}
|
||||
const rowsR = fill ? s.r1 - s.r0 + 1 : grid.length;
|
||||
const colsR = fill ? s.c1 - s.c0 + 1 : Math.min(grid[0].length, this.cols.length - s.c0);
|
||||
for (let i = 0; i < rowsR; i++) {
|
||||
const row = this.rows[this.view[s.r0 + i]];
|
||||
if (!row) break;
|
||||
for (let j = 0; j < colsR; j++) {
|
||||
const c = s.c0 + j;
|
||||
if (c >= this.cols.length) break;
|
||||
const v = fill ? grid[0][0] : grid[i][j] ?? "";
|
||||
list.push({ row, c, v: this.cols[c].text ? v : v.trim() });
|
||||
}
|
||||
}
|
||||
if (after) {
|
||||
// rows added and filled: one undo step for both
|
||||
const before = this.rows.slice(0, this.rows.length - need);
|
||||
for (const x of list) x.row.v[x.c] = x.v;
|
||||
this.undo.push({ before, after: this.rows });
|
||||
this.redo = [];
|
||||
this.layout();
|
||||
} else this.change(list);
|
||||
this.anchor = { r: s.r0, c: s.c0 };
|
||||
this.act = { r: s.r0 + rowsR - 1, c: s.c0 + colsR - 1 };
|
||||
this.draw();
|
||||
status();
|
||||
toast(`Pasted ${rowsR} × ${colsR} cells${need > 0 ? ` (${need} new rows)` : ""}.`);
|
||||
}
|
||||
|
||||
selectedRows() {
|
||||
const s = this.sel();
|
||||
return this.view.slice(s.r0, s.r1 + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTsv(text) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let cell = "";
|
||||
let q = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (q) {
|
||||
if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++;
|
||||
else if (ch === '"') q = false;
|
||||
else cell += ch;
|
||||
} else if (ch === '"' && cell === "") q = true;
|
||||
else if (ch === "\t") row.push(cell), (cell = "");
|
||||
else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = "");
|
||||
else cell += ch;
|
||||
}
|
||||
if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------- row commands ----------
|
||||
function addRow() {
|
||||
const S = T.cur;
|
||||
if (!S) return;
|
||||
const at = S.view.length ? S.view[S.sel().r1] + 1 : S.rows.length;
|
||||
const blank = { o: -1, v: S.cols.map((c) => (c.text ? "" : "0")) };
|
||||
// a new key: the first column one above the largest when it's a number
|
||||
const key = S.cols[0];
|
||||
if (key && !key.text) blank.v[0] = String(Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1);
|
||||
const after = [...S.rows.slice(0, at), blank, ...S.rows.slice(at)];
|
||||
S.rowsStep(after);
|
||||
const vr = S.view.indexOf(at);
|
||||
if (vr >= 0) {
|
||||
S.act = { r: vr, c: 0 };
|
||||
S.anchor = { ...S.act };
|
||||
S.scrollTo(vr, 0);
|
||||
S.draw();
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateRows() {
|
||||
const S = T.cur;
|
||||
if (!S || !S.view.length) return;
|
||||
const pick = S.selectedRows().sort((a, b) => a - b);
|
||||
const at = pick[pick.length - 1] + 1;
|
||||
let next = Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1;
|
||||
const copies = pick.map((i) => {
|
||||
const v = [...S.rows[i].v];
|
||||
if (!S.cols[0].text) v[0] = String(next++);
|
||||
return { o: -1, v };
|
||||
});
|
||||
S.rowsStep([...S.rows.slice(0, at), ...copies, ...S.rows.slice(at)]);
|
||||
toast(`Duplicated ${copies.length} row(s)${!S.cols[0].text ? ` with new ${S.cols[0].name} values` : ""}.`);
|
||||
}
|
||||
|
||||
async function deleteRows() {
|
||||
const S = T.cur;
|
||||
if (!S || !S.view.length) return;
|
||||
const pick = new Set(S.selectedRows());
|
||||
if (pick.size > 1 && !(await confirmBox("Delete rows", `Delete ${pick.size} rows?`, "Delete", "danger"))) return;
|
||||
S.rowsStep(S.rows.filter((_, i) => !pick.has(i)));
|
||||
}
|
||||
|
||||
// ---------- save / import / export ----------
|
||||
async function save() {
|
||||
const S = T.cur;
|
||||
if (!S || T.busy || !S.dirty()) return;
|
||||
S.commitEdit();
|
||||
T.busy = true;
|
||||
try {
|
||||
const enums = Object.fromEntries(S.cols.filter((c) => c.enums).map((c) => [c.name, c.enums]));
|
||||
const r = await call(
|
||||
"dat:save",
|
||||
S.id,
|
||||
S.rows.map((x) => ({ o: x.o, v: x.v })),
|
||||
{ enums },
|
||||
);
|
||||
S.info = r;
|
||||
S.setData(r.rows);
|
||||
S.undo = [];
|
||||
S.redo = [];
|
||||
S.applyView();
|
||||
S.layout();
|
||||
toast(`Saved ${S.name}: ${r.rows.length} rows${r.backup ? ` (backup: ${r.backup.split(/[\\/]/).pop()})` : ""}.`);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
T.busy = false;
|
||||
status();
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTable() {
|
||||
const S = T.cur;
|
||||
if (!S) return;
|
||||
const file = await call("dialog:saveAs", `${S.name}.xlsx`, "xlsx");
|
||||
if (!file) return;
|
||||
try {
|
||||
const rows = S.rows.map((r) => r.v);
|
||||
const out = /\.csv$/i.test(file) ? await call("dat:exportCsv", S.id, rows, file) : await call("dat:exportXlsx", S.id, rows, file);
|
||||
toast(`Exported ${rows.length} rows to ${out}`);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function importTable() {
|
||||
const S = T.cur;
|
||||
if (!S) return;
|
||||
const file = await call("dat:pickFile", "Import rows from .xlsx / .csv", "sheet");
|
||||
if (!file) return;
|
||||
let r;
|
||||
try {
|
||||
r = await call("dat:import", S.id, file);
|
||||
} catch (err) {
|
||||
return toast(err.message, true);
|
||||
}
|
||||
const notes = [r.missing.length ? `Columns not in the file (left empty / 0): ${r.missing.join(", ")}.` : null, r.extra.length ? `Columns in the file that the table doesn't have (ignored): ${r.extra.join(", ")}.` : null].filter(Boolean);
|
||||
const ok = await confirmBox("Import", [`Replace the ${S.rows.length} rows of ${S.name} with the ${r.rows.length} rows of ${file.split(/[\\/]/).pop()}? You can undo it, and nothing is written until you Save.`, ...notes].join("\n\n"), "Replace");
|
||||
if (!ok) return;
|
||||
// rows that match an existing key keep their original (so unchanged cells stay byte-identical)
|
||||
const byKey = new Map(S.rows.map((x) => [x.v[0], x.o]));
|
||||
S.rowsStep(r.rows.map((v) => ({ o: byKey.has(v[0]) ? byKey.get(v[0]) : -1, v: v.map((x, i) => (S.cols[i].text ? x : x.trim() || "0")) })));
|
||||
}
|
||||
|
||||
// ---------- status ----------
|
||||
function status() {
|
||||
const S = T.cur;
|
||||
$("dt-save").disabled = !S || !S.dirty() || T.busy;
|
||||
$("dt-undo").disabled = !S?.undo.length;
|
||||
$("dt-redo").disabled = !S?.redo.length;
|
||||
for (const id of ["dt-add", "dt-dup", "dt-del", "dt-import", "dt-export", "dt-enc", "dt-search"]) $(id).disabled = !S;
|
||||
if (!S) return ($("dt-status").textContent = "");
|
||||
const s = S.sel();
|
||||
const changed = S.rows.filter((r) => r.o < 0 || r.v.some((v, c) => v !== S.orig[r.o]?.[c])).length;
|
||||
const col = S.cols[S.act.c];
|
||||
const row = S.rows[S.view[S.act.r]];
|
||||
$("dt-status").textContent = [
|
||||
`${S.name} · ${S.info.label}`,
|
||||
`${S.rows.length} rows${S.view.length !== S.rows.length ? ` (${S.view.length} shown)` : ""} × ${S.cols.length} columns · ${S.info.rowSize} bytes/row · header ${S.info.headerVersion}`,
|
||||
changed || S.rows.length !== S.orig.length ? `${changed} changed row(s)${S.rows.length !== S.orig.length ? `, ${S.rows.length - S.orig.length >= 0 ? "+" : ""}${S.rows.length - S.orig.length} rows` : ""}` : "no changes",
|
||||
col && row ? `${col.name} (${col.typeName})${col.meta?.desc ? ` — ${col.meta.desc}` : ""}${s.r1 > s.r0 || s.c1 > s.c0 ? ` · ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} selected` : ""}` : "",
|
||||
S.meta?.source ? `source: ${S.meta.source}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
// ---------- wiring ----------
|
||||
$("dt-add").addEventListener("click", addRow);
|
||||
$("dt-dup").addEventListener("click", duplicateRows);
|
||||
$("dt-del").addEventListener("click", deleteRows);
|
||||
$("dt-undo").addEventListener("click", () => T.cur?.doUndo());
|
||||
$("dt-redo").addEventListener("click", () => T.cur?.doRedo());
|
||||
$("dt-save").addEventListener("click", save);
|
||||
$("dt-export").addEventListener("click", exportTable);
|
||||
$("dt-import").addEventListener("click", importTable);
|
||||
$("dt-search").addEventListener("input", () => {
|
||||
if (!T.cur) return;
|
||||
T.cur.filter = $("dt-search").value.trim();
|
||||
T.cur.applyView();
|
||||
T.cur.layout();
|
||||
status();
|
||||
});
|
||||
$("dt-enc").addEventListener("change", async () => {
|
||||
const S = T.cur;
|
||||
if (!S) return;
|
||||
if (S.dirty() && !(await confirmBox("Code page", "Reading the texts in another code page drops the unsaved changes.", "Switch", "danger"))) {
|
||||
$("dt-enc").value = S.info.encoding;
|
||||
return;
|
||||
}
|
||||
const d = await call("dat:encoding", S.id, $("dt-enc").value);
|
||||
S.info = { ...S.info, ...d };
|
||||
S.setData(d.rows);
|
||||
S.undo = [];
|
||||
S.redo = [];
|
||||
S.applyView();
|
||||
S.layout();
|
||||
status();
|
||||
});
|
||||
$("dt-client").addEventListener("click", async () => {
|
||||
const dir = await call("dialog:folder", "Game folder (with the .iop packs)");
|
||||
if (!dir) return;
|
||||
T.src = { kind: "packs", folder: dir, name: dir };
|
||||
listTables();
|
||||
});
|
||||
$("dt-server").addEventListener("click", async () => {
|
||||
const dir = await call("dialog:folder", "The game server's tables folder (ls_gamesvr\\config\\tables)");
|
||||
if (!dir) return;
|
||||
LS("dt:server", dir);
|
||||
listTables();
|
||||
});
|
||||
$("dt-file").addEventListener("click", async () => {
|
||||
const file = await call("dat:pickFile", "Open a .dat table", "dat");
|
||||
if (file) openTable({ kind: "file", file }, `file|${file}`);
|
||||
});
|
||||
$("dt-maker").addEventListener("click", async () => {
|
||||
const dir = await call("dialog:folder", "ioDataMaker folder (with Config.xlsx and ExcelData)");
|
||||
if (!dir) return;
|
||||
LS("dt:maker", dir);
|
||||
toast("Column descriptions and enum names come from there now (reopen the table).");
|
||||
});
|
||||
$("dt-reload").addEventListener("click", listTables);
|
||||
window.addEventListener("beforeunload", () => T.cur && call("dat:close", T.cur.id));
|
||||
document.querySelector('#tabs [data-view="tables"]').addEventListener("click", () => {
|
||||
if (!T.listed) {
|
||||
T.listed = true;
|
||||
listTables();
|
||||
status();
|
||||
}
|
||||
});
|
||||
window.addEventListener("profile-changed", () => {
|
||||
T.listed = false;
|
||||
T.src = null;
|
||||
if ($("view-tables").classList.contains("on")) {
|
||||
T.listed = true;
|
||||
listTables();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,443 @@
|
||||
"use strict";
|
||||
|
||||
// Texture Studio maths (Texture tab): selections on RGBA pixels (magic wand, colour range, lasso,
|
||||
// rectangle), recolouring a selection, resizing, and .dds writing the way the game's textures are
|
||||
// stored (DXT1 / DXT3 / DXT5 or A8R8G8B8, with mip maps). Pure functions on Uint8(Clamped)Arrays,
|
||||
// no DOM: the Texture tab draws, this computes.
|
||||
|
||||
const TexKit = (() => {
|
||||
// =====================================================================
|
||||
// selections: a mask of w × h bytes (0 = out, 255 = in, between = feathered)
|
||||
// =====================================================================
|
||||
const dist = (px, i, c) => Math.max(Math.abs(px[i] - c[0]), Math.abs(px[i + 1] - c[1]), Math.abs(px[i + 2] - c[2]), Math.abs(px[i + 3] - c[3]));
|
||||
|
||||
/** Pixels like the one at (x, y), within tol (0–255 per channel): touching it (contiguous) or anywhere. */
|
||||
function wand(px, w, h, x, y, tol, contiguous = true) {
|
||||
const out = new Uint8Array(w * h);
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return out;
|
||||
const s = (y * w + x) * 4;
|
||||
const c = [px[s], px[s + 1], px[s + 2], px[s + 3]];
|
||||
if (!contiguous) {
|
||||
for (let i = 0; i < w * h; i++) if (dist(px, i * 4, c) <= tol) out[i] = 255;
|
||||
return out;
|
||||
}
|
||||
const stack = new Int32Array(w * h);
|
||||
let n = 0;
|
||||
stack[n++] = y * w + x;
|
||||
out[y * w + x] = 255;
|
||||
while (n) {
|
||||
const i = stack[--n];
|
||||
const ix = i % w, iy = (i - ix) / w;
|
||||
for (const j of [ix > 0 ? i - 1 : -1, ix < w - 1 ? i + 1 : -1, iy > 0 ? i - w : -1, iy < h - 1 ? i + w : -1]) {
|
||||
if (j < 0 || out[j] || dist(px, j * 4, c) > tol) continue;
|
||||
out[j] = 255;
|
||||
stack[n++] = j;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A polygon [[x, y]…] (image pixels) filled even-odd, sampled at pixel centres. */
|
||||
function polygon(w, h, pts) {
|
||||
const out = new Uint8Array(w * h);
|
||||
if (pts.length < 3) return out;
|
||||
const xs = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const cy = y + 0.5;
|
||||
xs.length = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length];
|
||||
if ((y0 <= cy && y1 > cy) || (y1 <= cy && y0 > cy)) xs.push(x0 + ((cy - y0) / (y1 - y0)) * (x1 - x0));
|
||||
}
|
||||
xs.sort((a, b) => a - b);
|
||||
for (let k = 0; k + 1 < xs.length; k += 2) {
|
||||
const a = Math.max(0, Math.ceil(xs[k] - 0.5)), b = Math.min(w - 1, Math.floor(xs[k + 1] - 0.5));
|
||||
for (let x = a; x <= b; x++) out[y * w + x] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const rect = (w, h, x0, y0, x1, y1) => polygon(w, h, [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
|
||||
|
||||
/** Mask a combined with b: "new" | "add" | "sub" | "and". */
|
||||
function combine(a, b, mode) {
|
||||
if (mode === "new" || !a) return b;
|
||||
const out = new Uint8Array(a.length);
|
||||
for (let i = 0; i < a.length; i++)
|
||||
out[i] = mode === "add" ? Math.max(a[i], b[i]) : mode === "sub" ? Math.min(a[i], 255 - b[i]) : Math.min(a[i], b[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
const invert = (m) => m.map((v) => 255 - v);
|
||||
|
||||
/** Grows (r > 0) or shrinks (r < 0) a mask by |r| pixels (square neighbourhood). */
|
||||
function grow(m, w, h, r) {
|
||||
if (!r) return m;
|
||||
const pick = r > 0 ? Math.max : Math.min;
|
||||
let cur = m;
|
||||
for (let it = 0; it < Math.abs(r); it++) {
|
||||
const out = new Uint8Array(cur.length);
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++) {
|
||||
let v = cur[y * w + x];
|
||||
if (x > 0) v = pick(v, cur[y * w + x - 1]);
|
||||
if (x < w - 1) v = pick(v, cur[y * w + x + 1]);
|
||||
if (y > 0) v = pick(v, cur[(y - 1) * w + x]);
|
||||
if (y < h - 1) v = pick(v, cur[(y + 1) * w + x]);
|
||||
out[y * w + x] = v;
|
||||
}
|
||||
cur = out;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Soft edges: a box blur of radius r (twice, about a Gaussian). */
|
||||
function feather(m, w, h, r) {
|
||||
if (!r) return m;
|
||||
let a = Float32Array.from(m);
|
||||
const pass = (src, horiz) => {
|
||||
const out = new Float32Array(src.length);
|
||||
const n = horiz ? w : h, lines = horiz ? h : w;
|
||||
for (let l = 0; l < lines; l++) {
|
||||
const at = (k) => (horiz ? l * w + k : k * w + l);
|
||||
let sum = 0;
|
||||
for (let k = -r; k <= r; k++) sum += src[at(Math.max(0, Math.min(n - 1, k)))];
|
||||
for (let k = 0; k < n; k++) {
|
||||
out[at(k)] = sum / (2 * r + 1);
|
||||
sum += src[at(Math.min(n - 1, k + r + 1))] - src[at(Math.max(0, k - r))];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
for (let i = 0; i < 2; i++) a = pass(pass(a, true), false);
|
||||
return Uint8Array.from(a, (v) => Math.round(v));
|
||||
}
|
||||
|
||||
const count = (m) => m.reduce((s, v) => s + (v >= 128 ? 1 : 0), 0);
|
||||
|
||||
// =====================================================================
|
||||
// recolour
|
||||
// =====================================================================
|
||||
function rgb2hsl(r, g, b) {
|
||||
r /= 255, g /= 255, b /= 255;
|
||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
|
||||
if (mx === mn) return [0, 0, l];
|
||||
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
||||
const hh = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
return [hh / 6, s, l];
|
||||
}
|
||||
function hsl2rgb(hh, s, l) {
|
||||
if (!s) return [l * 255, l * 255, l * 255];
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
|
||||
const f = (t) => {
|
||||
t = ((t % 1) + 1) % 1;
|
||||
return 255 * (t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p);
|
||||
};
|
||||
return [f(hh + 1 / 3), f(hh), f(hh - 1 / 3)];
|
||||
}
|
||||
|
||||
/** How bright the selection's brightest parts are (98th percentile of max(r, g, b)), for colourise. */
|
||||
function peak(px, mask) {
|
||||
const hist = new Uint32Array(256);
|
||||
let n = 0;
|
||||
for (let i = 0; i < px.length / 4; i++) {
|
||||
if (mask && mask[i] < 128) continue;
|
||||
hist[Math.max(px[i * 4], px[i * 4 + 1], px[i * 4 + 2])]++;
|
||||
n++;
|
||||
}
|
||||
let acc = 0;
|
||||
for (let v = 0; v < 256; v++) if ((acc += hist[v]) >= n * 0.98) return Math.max(1, v);
|
||||
return 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* src → dst (same size) with the selection recoloured; outside it dst = src. opts:
|
||||
* { mode: "colorize" | "adjust", color: [r, g, b], strength (0–1), bright (−1…1), hue (−180…180°),
|
||||
* sat (−1…1), light (−1…1), contrast (−1…1), ref (colorize: the source's peak) }
|
||||
* colorize: each pixel's brightness (its max channel against the selection's brightest) times the
|
||||
* colour, so a dark hair texture keeps its strands; adjust: hue / saturation / lightness shifts.
|
||||
*/
|
||||
function recolor(src, dst, mask, opts) {
|
||||
const o = { mode: "colorize", color: [200, 160, 60], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0, ...opts };
|
||||
const ref = o.ref || peak(src, mask);
|
||||
const gain = 1 + o.bright;
|
||||
const k = 1 + o.contrast;
|
||||
for (let i = 0; i < src.length / 4; i++) {
|
||||
const p = i * 4, m = mask ? mask[i] / 255 : 1;
|
||||
dst[p + 3] = src[p + 3];
|
||||
if (!m) {
|
||||
dst[p] = src[p], dst[p + 1] = src[p + 1], dst[p + 2] = src[p + 2];
|
||||
continue;
|
||||
}
|
||||
let r = src[p], g = src[p + 1], b = src[p + 2];
|
||||
if (o.mode === "colorize") {
|
||||
const t = Math.max(r, g, b) / ref;
|
||||
const c = o.color;
|
||||
r = r + (c[0] * t * gain - r) * o.strength;
|
||||
g = g + (c[1] * t * gain - g) * o.strength;
|
||||
b = b + (c[2] * t * gain - b) * o.strength;
|
||||
} else {
|
||||
let [hh, s, l] = rgb2hsl(r, g, b);
|
||||
hh += o.hue / 360;
|
||||
s = o.sat >= 0 ? s + (1 - s) * o.sat : s * (1 + o.sat);
|
||||
l = o.light >= 0 ? l + (1 - l) * o.light : l * (1 + o.light);
|
||||
[r, g, b] = hsl2rgb(hh, Math.max(0, Math.min(1, s)), Math.max(0, Math.min(1, l)));
|
||||
}
|
||||
if (o.contrast) (r = (r - 128) * k + 128), (g = (g - 128) * k + 128), (b = (b - 128) * k + 128);
|
||||
dst[p] = src[p] + (r - src[p]) * m;
|
||||
dst[p + 1] = src[p + 1] + (g - src[p + 1]) * m;
|
||||
dst[p + 2] = src[p + 2] + (b - src[p + 2]) * m;
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// sizes
|
||||
// =====================================================================
|
||||
const pow2 = (n) => 2 ** Math.floor(Math.log2(Math.max(1, n)));
|
||||
|
||||
/** RGBA w × h → nw × nh (area average going down, bilinear going up). */
|
||||
function resize(px, w, h, nw, nh) {
|
||||
if (nw === w && nh === h) return Uint8ClampedArray.from(px);
|
||||
const out = new Uint8ClampedArray(nw * nh * 4);
|
||||
const sx = w / nw, sy = h / nh;
|
||||
for (let y = 0; y < nh; y++)
|
||||
for (let x = 0; x < nw; x++) {
|
||||
const acc = [0, 0, 0, 0];
|
||||
let n = 0;
|
||||
if (sx >= 1 && sy >= 1) {
|
||||
for (let yy = Math.floor(y * sy); yy < Math.min(h, Math.ceil((y + 1) * sy)); yy++)
|
||||
for (let xx = Math.floor(x * sx); xx < Math.min(w, Math.ceil((x + 1) * sx)); xx++) {
|
||||
const p = (yy * w + xx) * 4;
|
||||
for (let c = 0; c < 4; c++) acc[c] += px[p + c];
|
||||
n++;
|
||||
}
|
||||
} else {
|
||||
const fx = Math.max(0, (x + 0.5) * sx - 0.5), fy = Math.max(0, (y + 0.5) * sy - 0.5);
|
||||
const x0 = Math.min(w - 1, Math.floor(fx)), y0 = Math.min(h - 1, Math.floor(fy));
|
||||
const x1 = Math.min(w - 1, x0 + 1), y1 = Math.min(h - 1, y0 + 1), ax = fx - x0, ay = fy - y0;
|
||||
for (const [xx, yy, f] of [[x0, y0, (1 - ax) * (1 - ay)], [x1, y0, ax * (1 - ay)], [x0, y1, (1 - ax) * ay], [x1, y1, ax * ay]]) {
|
||||
const p = (yy * w + xx) * 4;
|
||||
for (let c = 0; c < 4; c++) acc[c] += px[p + c] * f;
|
||||
}
|
||||
n = 1;
|
||||
}
|
||||
const q = (y * nw + x) * 4;
|
||||
for (let c = 0; c < 4; c++) out[q + c] = acc[c] / n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every pixel opaque / alpha only 0 or 255 / real alpha. */
|
||||
function alphaKind(px) {
|
||||
let binary = true, opaque = true;
|
||||
for (let i = 3; i < px.length; i += 4) {
|
||||
const a = px[i];
|
||||
if (a !== 255) opaque = false;
|
||||
if (a !== 0 && a !== 255) {
|
||||
binary = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return opaque ? "opaque" : binary ? "binary" : "alpha";
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DXT (BC1 / BC2 / BC3) encoding
|
||||
// =====================================================================
|
||||
const to565 = (r, g, b) => ((Math.round((r * 31) / 255) << 11) | (Math.round((g * 63) / 255) << 5) | Math.round((b * 31) / 255)) >>> 0;
|
||||
const from565 = (c) => [(((c >> 11) & 31) * 255) / 31, (((c >> 5) & 63) * 255) / 63, ((c & 31) * 255) / 31];
|
||||
|
||||
/** The 16 pixels of block (bx, by), edge pixels repeated: Float32Array(64). */
|
||||
function block(px, w, h, bx, by) {
|
||||
const b = new Float32Array(64);
|
||||
for (let j = 0; j < 4; j++)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = Math.min(w - 1, bx * 4 + i), y = Math.min(h - 1, by * 4 + j);
|
||||
const p = (y * w + x) * 4, q = (j * 4 + i) * 4;
|
||||
b[q] = px[p], b[q + 1] = px[p + 1], b[q + 2] = px[p + 2], b[q + 3] = px[p + 3];
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Colour part of a block (8 bytes) into out at o; use: which of the 16 pixels count (DXT1 transparent ones don't). */
|
||||
function colorBlock(b, out, o, dxt1Alpha) {
|
||||
const use = [];
|
||||
for (let k = 0; k < 16; k++) if (!dxt1Alpha || b[k * 4 + 3] >= 128) use.push(k);
|
||||
const dv = new DataView(out.buffer, out.byteOffset);
|
||||
if (!use.length) {
|
||||
// all transparent: 3-colour mode, every index 3 (transparent black)
|
||||
dv.setUint16(o, 0, true), dv.setUint16(o + 2, 0, true), dv.setUint32(o + 4, 0xffffffff, true);
|
||||
return;
|
||||
}
|
||||
// principal axis of the colours (a few power iterations), endpoints at the extremes along it
|
||||
const mean = [0, 0, 0];
|
||||
for (const k of use) for (let c = 0; c < 3; c++) mean[c] += b[k * 4 + c] / use.length;
|
||||
const cov = [0, 0, 0, 0, 0, 0];
|
||||
for (const k of use) {
|
||||
const r = b[k * 4] - mean[0], g = b[k * 4 + 1] - mean[1], bl = b[k * 4 + 2] - mean[2];
|
||||
cov[0] += r * r, cov[1] += r * g, cov[2] += r * bl, cov[3] += g * g, cov[4] += g * bl, cov[5] += bl * bl;
|
||||
}
|
||||
let ax = [1, 1, 1];
|
||||
for (let it = 0; it < 6; it++) {
|
||||
const n = [cov[0] * ax[0] + cov[1] * ax[1] + cov[2] * ax[2], cov[1] * ax[0] + cov[3] * ax[1] + cov[4] * ax[2], cov[2] * ax[0] + cov[4] * ax[1] + cov[5] * ax[2]];
|
||||
const l = Math.hypot(...n);
|
||||
if (l < 1e-6) break;
|
||||
ax = n.map((v) => v / l);
|
||||
}
|
||||
let lo = Infinity, hi = -Infinity, kl = use[0], kh = use[0];
|
||||
for (const k of use) {
|
||||
const t = (b[k * 4] - mean[0]) * ax[0] + (b[k * 4 + 1] - mean[1]) * ax[1] + (b[k * 4 + 2] - mean[2]) * ax[2];
|
||||
if (t < lo) (lo = t), (kl = k);
|
||||
if (t > hi) (hi = t), (kh = k);
|
||||
}
|
||||
// the endpoints a touch inside the extremes (less error for the middle colours)
|
||||
const inset = (k, toward) => [0, 1, 2].map((c) => b[k * 4 + c] + (b[toward * 4 + c] - b[k * 4 + c]) / 16);
|
||||
const e0 = inset(kh, kl), e1 = inset(kl, kh);
|
||||
let c0 = to565(...e0), c1 = to565(...e1);
|
||||
const threeColor = dxt1Alpha && use.length < 16;
|
||||
if (threeColor ? c0 > c1 : c0 < c1) [c0, c1] = [c1, c0];
|
||||
if (!threeColor && c0 === c1) {
|
||||
// one colour: 4-colour mode needs c0 > c1
|
||||
if (c1 > 0) c1--;
|
||||
else c0++;
|
||||
}
|
||||
const p0 = from565(c0), p1 = from565(c1);
|
||||
const pal = threeColor
|
||||
? [p0, p1, p0.map((v, i) => (v + p1[i]) / 2)]
|
||||
: [p0, p1, p0.map((v, i) => (2 * v + p1[i]) / 3), p0.map((v, i) => (v + 2 * p1[i]) / 3)];
|
||||
let bits = 0;
|
||||
for (let k = 15; k >= 0; k--) {
|
||||
let idx;
|
||||
if (threeColor && b[k * 4 + 3] < 128) idx = 3;
|
||||
else {
|
||||
let best = Infinity;
|
||||
idx = 0;
|
||||
for (let i = 0; i < pal.length; i++) {
|
||||
const c = pal[i], dr = c[0] - b[k * 4], dg = c[1] - b[k * 4 + 1], db = c[2] - b[k * 4 + 2];
|
||||
const d = dr * dr + dg * dg + db * db;
|
||||
if (d < best) (best = d), (idx = i);
|
||||
}
|
||||
}
|
||||
bits = (bits << 2) | idx;
|
||||
}
|
||||
dv.setUint16(o, c0, true);
|
||||
dv.setUint16(o + 2, c1, true);
|
||||
dv.setUint32(o + 4, bits >>> 0, true);
|
||||
}
|
||||
|
||||
/** DXT5 alpha (8 bytes): two endpoints, 3-bit indices. */
|
||||
function alphaBlock5(b, out, o) {
|
||||
let lo = 255, hi = 0;
|
||||
for (let k = 0; k < 16; k++) (lo = Math.min(lo, b[k * 4 + 3])), (hi = Math.max(hi, b[k * 4 + 3]));
|
||||
lo = Math.round(lo), hi = Math.round(hi);
|
||||
// a0 > a1: the 8-value mode (one alpha: a0 = a1, every pixel takes index 0 exactly)
|
||||
const a0 = hi, a1 = lo;
|
||||
const pal = [a0, a1];
|
||||
for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7);
|
||||
out[o] = a0, out[o + 1] = a1;
|
||||
// 16 indices × 3 bits: pixels 0–7 in the first 3 bytes, 8–15 in the next 3
|
||||
for (let half = 0; half < 2; half++) {
|
||||
let bits = 0;
|
||||
for (let k = 7; k >= 0; k--) {
|
||||
const a = b[(half * 8 + k) * 4 + 3];
|
||||
let best = Infinity, idx = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const d = Math.abs(pal[i] - a);
|
||||
if (d < best) (best = d), (idx = i);
|
||||
}
|
||||
bits = (bits << 3) | idx;
|
||||
}
|
||||
for (let i = 0; i < 3; i++) out[o + 2 + half * 3 + i] = (bits >> (8 * i)) & 255;
|
||||
}
|
||||
}
|
||||
|
||||
/** DXT3 alpha (8 bytes): 4 bits a pixel. */
|
||||
function alphaBlock3(b, out, o) {
|
||||
for (let k = 0; k < 16; k += 2) out[o + k / 2] = (Math.round(b[k * 4 + 3] / 17) & 15) | ((Math.round(b[(k + 1) * 4 + 3] / 17) & 15) << 4);
|
||||
}
|
||||
|
||||
/** One mip level → its DXT bytes. */
|
||||
function dxt(px, w, h, kind) {
|
||||
const bw = Math.max(1, Math.ceil(w / 4)), bh = Math.max(1, Math.ceil(h / 4));
|
||||
const size = kind === "DXT1" ? 8 : 16;
|
||||
const out = new Uint8Array(bw * bh * size);
|
||||
for (let by = 0; by < bh; by++)
|
||||
for (let bx = 0; bx < bw; bx++) {
|
||||
const b = block(px, w, h, bx, by), o = (by * bw + bx) * size;
|
||||
if (kind === "DXT1") colorBlock(b, out, o, true);
|
||||
else {
|
||||
(kind === "DXT5" ? alphaBlock5 : alphaBlock3)(b, out, o);
|
||||
colorBlock(b, out, o + 8, false);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "auto" → what the game would use: opaque or cut-out (transparent black) DXT1, real alpha DXT5. */
|
||||
const pickFormat = (px, format) => (format && format !== "auto" ? format : alphaKind(px) === "alpha" ? "DXT5" : "DXT1");
|
||||
|
||||
/**
|
||||
* RGBA → a .dds: { format: "auto" | "DXT1" | "DXT3" | "DXT5" | "ARGB", mips: true }. → { bytes, format, levels }
|
||||
*/
|
||||
function encodeDds(px, w, h, opts = {}) {
|
||||
const format = pickFormat(px, opts.format);
|
||||
const levels = [];
|
||||
let cur = px, cw = w, ch = h;
|
||||
for (;;) {
|
||||
levels.push(format === "ARGB" ? argb(cur) : dxt(cur, cw, ch, format));
|
||||
if (!opts.mips || (cw === 1 && ch === 1)) break;
|
||||
const nw = Math.max(1, cw >> 1), nh = Math.max(1, ch >> 1);
|
||||
cur = resize(cur, cw, ch, nw, nh);
|
||||
(cw = nw), (ch = nh);
|
||||
}
|
||||
const total = levels.reduce((s, l) => s + l.length, 0);
|
||||
const out = new Uint8Array(128 + total);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, 0x20534444, true); // "DDS "
|
||||
dv.setUint32(4, 124, true);
|
||||
const compressed = format !== "ARGB";
|
||||
// caps | height | width | pixel format, + pitch or linear size, + mip map count
|
||||
dv.setUint32(8, 0x1007 | (compressed ? 0x80000 : 0x8) | (levels.length > 1 ? 0x20000 : 0), true);
|
||||
dv.setUint32(12, h, true);
|
||||
dv.setUint32(16, w, true);
|
||||
dv.setUint32(20, compressed ? levels[0].length : w * 4, true);
|
||||
dv.setUint32(28, levels.length > 1 ? levels.length : 0, true);
|
||||
dv.setUint32(76, 32, true);
|
||||
if (compressed) {
|
||||
dv.setUint32(80, 0x4, true); // FOURCC
|
||||
for (let i = 0; i < 4; i++) out[84 + i] = format.charCodeAt(i);
|
||||
} else {
|
||||
dv.setUint32(80, 0x41, true); // RGB | alpha pixels
|
||||
dv.setUint32(88, 32, true);
|
||||
dv.setUint32(92, 0x00ff0000, true);
|
||||
dv.setUint32(96, 0x0000ff00, true);
|
||||
dv.setUint32(100, 0x000000ff, true);
|
||||
dv.setUint32(104, 0xff000000, true);
|
||||
}
|
||||
dv.setUint32(108, 0x1000 | (levels.length > 1 ? 0x400008 : 0), true); // texture (| mip map | complex)
|
||||
let o = 128;
|
||||
for (const l of levels) out.set(l, o), (o += l.length);
|
||||
return { bytes: out, format, levels: levels.length };
|
||||
}
|
||||
|
||||
function argb(px) {
|
||||
const out = new Uint8Array(px.length);
|
||||
for (let i = 0; i < px.length; i += 4) (out[i] = px[i + 2]), (out[i + 1] = px[i + 1]), (out[i + 2] = px[i]), (out[i + 3] = px[i + 3]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What a .dds is stored as (its header): { format, w, h, mips }. */
|
||||
function ddsInfo(bytes) {
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (bytes.length < 128 || dv.getUint32(0, true) !== 0x20534444) return null;
|
||||
const pf = dv.getUint32(80, true);
|
||||
const format = pf & 4 ? String.fromCharCode(...bytes.subarray(84, 88)) : `RGB${dv.getUint32(88, true)}`;
|
||||
return { format, w: dv.getUint32(16, true), h: dv.getUint32(12, true), mips: Math.max(1, dv.getUint32(28, true)) };
|
||||
}
|
||||
|
||||
return { wand, polygon, rect, combine, invert, grow, feather, count, peak, recolor, rgb2hsl, hsl2rgb, resize, pow2, alphaKind, pickFormat, encodeDds, ddsInfo };
|
||||
})();
|
||||
|
||||
if (typeof window !== "undefined") window.TexKit = TexKit;
|
||||
@@ -0,0 +1,724 @@
|
||||
"use strict";
|
||||
|
||||
// Texture Studio (Texture tab): open the pictures of a folder (.png / .jpg / .tga / .bmp / .dds),
|
||||
// select parts of one (magic wand, colour range, lasso, rectangle), recolour the selection (colourise
|
||||
// to a colour keeping the shading, or hue / saturation / lightness), and save it as a .dds the way the
|
||||
// game stores textures (DXT1 / DXT3 / DXT5 / A8R8G8B8, with mip maps) — one file or a whole folder.
|
||||
// The maths is in texkit.js.
|
||||
|
||||
(() => {
|
||||
const TK = TexKit;
|
||||
const TEX = {
|
||||
dir: null,
|
||||
files: [], // { name, size, mtime }
|
||||
file: null, // { path, name, stem, ext, info (a .dds: its format) }
|
||||
w: 0,
|
||||
h: 0,
|
||||
base: null, // the pixels as they are now (Uint8ClampedArray RGBA)
|
||||
shown: null, // what the view shows: base, or base with the recolour previewed
|
||||
raw: null, // the selection as picked (Uint8Array, 0 / 255)
|
||||
mask: null, // raw, gaps closed and feathered (what the recolour uses)
|
||||
undo: [],
|
||||
redo: [],
|
||||
tool: "wand",
|
||||
tol: 24,
|
||||
contiguous: true,
|
||||
close: 0,
|
||||
feather: 1,
|
||||
rc: { mode: "colorize", color: [230, 190, 100], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0 },
|
||||
rcOn: false, // the recolour is previewed (not applied yet)
|
||||
showSel: true,
|
||||
picking: false, // the next click takes the colourise colour
|
||||
exp: { format: "auto", mips: true, max: 0 },
|
||||
dirty: false,
|
||||
zoom: 1,
|
||||
pan: [0, 0],
|
||||
fitted: true, // the view follows the window size until zoomed / moved
|
||||
};
|
||||
window.TEX = TEX;
|
||||
const KEY = "tex:dir";
|
||||
const stem = (n) => n.replace(/\.[^.]+$/, "");
|
||||
const extOf = (n) => (n.match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
|
||||
const join = (d, n) => `${d.replace(/[\\/]+$/, "")}\\${n}`;
|
||||
const hex = (c) => "#" + c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
|
||||
const unhex = (s) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
|
||||
const field = (label, input) => [el("label", { text: label }), input];
|
||||
const fill = (box, ...kids) => box.replaceChildren(...kids.filter(Boolean));
|
||||
|
||||
let cv, g, imgCanvas, selCanvas;
|
||||
let started = false;
|
||||
|
||||
// =====================================================================
|
||||
// folder + files
|
||||
// =====================================================================
|
||||
async function openFolder(dir) {
|
||||
dir = dir || (await call("dialog:folder", "Folder with the textures"));
|
||||
if (!dir) return;
|
||||
TEX.dir = dir;
|
||||
try {
|
||||
localStorage.setItem(KEY, dir);
|
||||
} catch {}
|
||||
await listFiles();
|
||||
}
|
||||
async function listFiles() {
|
||||
if (!TEX.dir) return;
|
||||
try {
|
||||
TEX.files = (await call("tex:list", TEX.dir)).sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch (err) {
|
||||
TEX.files = [];
|
||||
toast(`${TEX.dir}: ${err.message}`, true);
|
||||
}
|
||||
$("tex-dir").textContent = TEX.dir;
|
||||
$("tex-dir").title = TEX.dir;
|
||||
renderList();
|
||||
}
|
||||
function renderList() {
|
||||
const q = $("tex-search").value.trim().toLowerCase();
|
||||
const rows = TEX.files.filter((f) => !q || f.name.toLowerCase().includes(q));
|
||||
$("tex-count").textContent = rows.length ? String(rows.length) : "";
|
||||
// web fix: draw 300 rows at a time, more while scrolling (10 000+ textures froze the page)
|
||||
const box = $("tex-list");
|
||||
const CHUNK = 300;
|
||||
let shown = Math.min(rows.length, Math.max(CHUNK, TEX._shown || 0));
|
||||
const rowEl = (f) =>
|
||||
el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [
|
||||
el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),
|
||||
el("span", { class: "grow", text: f.name }),
|
||||
el("span", { class: "dim", text: size(f.size) }),
|
||||
]);
|
||||
box.onscroll = () => {
|
||||
if (shown >= rows.length || box.scrollTop + box.clientHeight < box.scrollHeight - 400) return;
|
||||
box.append(...rows.slice(shown, shown + CHUNK).map(rowEl));
|
||||
shown = Math.min(rows.length, shown + CHUNK);
|
||||
TEX._shown = shown;
|
||||
};
|
||||
if (TEX._q !== q) (TEX._q = q), (shown = Math.min(rows.length, CHUNK)), (box.scrollTop = 0);
|
||||
TEX._shown = shown;
|
||||
$("tex-list").replaceChildren(
|
||||
...rows.slice(0, shown).map((f) =>
|
||||
el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [
|
||||
el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),
|
||||
el("span", { class: "grow", text: f.name }),
|
||||
el("span", { class: "dim", text: size(f.size) }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (!rows.length) $("tex-list").append(el("div", { class: "ans-note", text: TEX.dir ? "No pictures here (.png .jpg .tga .bmp .dds)." : "Open a folder with textures." }));
|
||||
}
|
||||
|
||||
async function keepChanges() {
|
||||
if (!TEX.dirty) return true;
|
||||
const r = await modal("Unsaved changes", `${TEX.file.name} has changes that aren't saved.`, [
|
||||
{ label: "Cancel", value: false },
|
||||
{ label: "Discard", kind: "danger", value: true },
|
||||
]);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function openFile(path) {
|
||||
if (!(await keepChanges())) return;
|
||||
const name = path.replace(/\\/g, "/").split("/").pop();
|
||||
const ext = extOf(name);
|
||||
loading(true, `Opening ${name}…`);
|
||||
try {
|
||||
const { bytes } = await call("mdl:readFile", path);
|
||||
const bmp = await TexDecode.decode(bytes, ext);
|
||||
const c = new OffscreenCanvas(bmp.width, bmp.height);
|
||||
const cx = c.getContext("2d", { willReadFrequently: true });
|
||||
cx.drawImage(bmp, 0, 0);
|
||||
const px = cx.getImageData(0, 0, bmp.width, bmp.height).data;
|
||||
TEX.file = { path, name, stem: stem(name), ext, dir: path.replace(/[\\/][^\\/]*$/, ""), info: ext === "dds" ? TK.ddsInfo(bytes) : null };
|
||||
TEX.w = bmp.width;
|
||||
TEX.h = bmp.height;
|
||||
TEX.base = new Uint8ClampedArray(px);
|
||||
TEX.raw = TEX.mask = null;
|
||||
TEX.undo = [];
|
||||
TEX.redo = [];
|
||||
TEX.rcOn = false;
|
||||
TEX.dirty = false;
|
||||
// a .dds keeps its own format when saved again
|
||||
if (TEX.file.info && /^DXT[135]$/.test(TEX.file.info.format)) TEX.exp.format = TEX.file.info.format;
|
||||
else if (TEX.file.info?.format === "RGB32") TEX.exp.format = "ARGB";
|
||||
else TEX.exp.format = "auto";
|
||||
TEX.exp.mips = TEX.file.info ? TEX.file.info.mips > 1 : true;
|
||||
$("tex-empty").hidden = true;
|
||||
refresh();
|
||||
fitView();
|
||||
renderList();
|
||||
renderSide();
|
||||
} catch (err) {
|
||||
toast(`${name}: ${err.message}`, true);
|
||||
} finally {
|
||||
loading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function loading(on, text) {
|
||||
$("tex-loading").hidden = !on;
|
||||
if (text) $("tex-loading").querySelector("span").textContent = text;
|
||||
}
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
|
||||
|
||||
// =====================================================================
|
||||
// pixels → view
|
||||
// =====================================================================
|
||||
/** Recomputes the effective mask, the preview and the view images. */
|
||||
function refresh() {
|
||||
if (!TEX.base) return;
|
||||
const { w, h } = TEX;
|
||||
if (TEX.raw) {
|
||||
let m = TEX.raw;
|
||||
if (TEX.close) m = TK.grow(TK.grow(m, w, h, TEX.close), w, h, -TEX.close);
|
||||
TEX.mask = TK.feather(m, w, h, TEX.feather);
|
||||
} else TEX.mask = null;
|
||||
TEX.shown = TEX.rcOn ? TK.recolor(TEX.base, new Uint8ClampedArray(TEX.base.length), TEX.mask, TEX.rc) : TEX.base;
|
||||
imgCanvas = imgCanvas && imgCanvas.width === w && imgCanvas.height === h ? imgCanvas : new OffscreenCanvas(w, h);
|
||||
imgCanvas.getContext("2d").putImageData(new ImageData(TEX.shown, w, h), 0, 0);
|
||||
drawSelection();
|
||||
$("tex-dirty").hidden = !TEX.dirty;
|
||||
$("tex-title").textContent = TEX.file ? `${TEX.file.name} — ${w}×${h}${TEX.file.info ? ` ${TEX.file.info.format}${TEX.file.info.mips > 1 ? " +mips" : ""}` : ""}` : "";
|
||||
$("tex-save").disabled = !TEX.base;
|
||||
$("tex-saveas").disabled = !TEX.base;
|
||||
draw();
|
||||
}
|
||||
|
||||
/** The selection's outline (and a light tint inside) as an image the size of the texture. */
|
||||
function drawSelection() {
|
||||
const { w, h, raw } = TEX;
|
||||
if (!raw) return (selCanvas = null);
|
||||
const id = new ImageData(w, h);
|
||||
const d = id.data;
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
if (raw[i] < 128) continue;
|
||||
const edge = x === 0 || y === 0 || x === w - 1 || y === h - 1 || raw[i - 1] < 128 || raw[i + 1] < 128 || raw[i - w] < 128 || raw[i + w] < 128;
|
||||
if (edge) d.set([60, 220, 255, 255], i * 4);
|
||||
else d.set([60, 180, 255, 38], i * 4);
|
||||
}
|
||||
selCanvas = new OffscreenCanvas(w, h);
|
||||
selCanvas.getContext("2d").putImageData(id, 0, 0);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!cv) return;
|
||||
const r = cv.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (cv.width !== Math.round(r.width * dpr) || cv.height !== Math.round(r.height * dpr)) (cv.width = Math.round(r.width * dpr)), (cv.height = Math.round(r.height * dpr));
|
||||
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||
g.clearRect(0, 0, cv.width, cv.height);
|
||||
if (!TEX.base) return;
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const z = TEX.zoom, [px, py] = TEX.pan;
|
||||
// checkerboard under the picture (transparent parts)
|
||||
g.save();
|
||||
g.beginPath();
|
||||
g.rect(px, py, TEX.w * z, TEX.h * z);
|
||||
g.clip();
|
||||
const s = 10;
|
||||
for (let y = Math.floor(py / s) * s; y < py + TEX.h * z; y += s)
|
||||
for (let x = Math.floor(px / s) * s; x < px + TEX.w * z; x += s) {
|
||||
g.fillStyle = ((x / s + y / s) & 1) === 0 ? "#2a2a30" : "#1e1e22";
|
||||
g.fillRect(x, y, s, s);
|
||||
}
|
||||
g.restore();
|
||||
g.imageSmoothingEnabled = z < 1;
|
||||
g.drawImage(imgCanvas, px, py, TEX.w * z, TEX.h * z);
|
||||
if (selCanvas && TEX.showSel) {
|
||||
g.imageSmoothingEnabled = false;
|
||||
g.drawImage(selCanvas, px, py, TEX.w * z, TEX.h * z);
|
||||
}
|
||||
// the lasso / rectangle being drawn
|
||||
if (drag?.pts?.length > 1) {
|
||||
g.strokeStyle = "#3cdcff";
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([4, 3]);
|
||||
g.beginPath();
|
||||
drag.pts.forEach(([x, y], i) => (i ? g.lineTo : g.moveTo).call(g, px + x * z, py + y * z));
|
||||
if (drag.tool === "rect") g.closePath();
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
}
|
||||
$("tex-zoom").textContent = `${Math.round(z * 100)}%`;
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
if (!cv || !TEX.base) return;
|
||||
const r = cv.getBoundingClientRect();
|
||||
TEX.zoom = Math.min((r.width - 40) / TEX.w, (r.height - 40) / TEX.h);
|
||||
TEX.zoom = TEX.zoom > 1 ? Math.floor(TEX.zoom) : TEX.zoom;
|
||||
TEX.pan = [(r.width - TEX.w * TEX.zoom) / 2, (r.height - TEX.h * TEX.zoom) / 2];
|
||||
TEX.fitted = true;
|
||||
draw();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// editing: undo, selection, recolour
|
||||
// =====================================================================
|
||||
function pushUndo() {
|
||||
TEX.undo.push(TEX.base);
|
||||
if (TEX.undo.length > 20) TEX.undo.shift();
|
||||
TEX.redo = [];
|
||||
}
|
||||
function undo(back = true) {
|
||||
const from = back ? TEX.undo : TEX.redo, to = back ? TEX.redo : TEX.undo;
|
||||
if (!from.length) return;
|
||||
to.push(TEX.base);
|
||||
TEX.base = from.pop();
|
||||
TEX.dirty = true;
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** A new selection piece: shift adds, alt takes away, else it replaces. */
|
||||
function select(piece, e) {
|
||||
const mode = e?.shiftKey ? "add" : e?.altKey ? "sub" : "new";
|
||||
TEX.raw = TK.combine(TEX.raw, piece, mode);
|
||||
if (!TK.count(TEX.raw)) TEX.raw = null;
|
||||
refresh();
|
||||
renderSel();
|
||||
}
|
||||
function selectAll(how) {
|
||||
if (!TEX.base) return;
|
||||
const n = TEX.w * TEX.h;
|
||||
if (how === "all") TEX.raw = new Uint8Array(n).fill(255);
|
||||
else if (how === "none") TEX.raw = null;
|
||||
else if (how === "invert") TEX.raw = TEX.raw ? TK.invert(TEX.raw) : new Uint8Array(n).fill(255);
|
||||
else if (TEX.raw) TEX.raw = TK.grow(TEX.raw, TEX.w, TEX.h, how);
|
||||
refresh();
|
||||
renderSel();
|
||||
}
|
||||
|
||||
function applyRecolor() {
|
||||
if (!TEX.rcOn) return;
|
||||
pushUndo();
|
||||
TEX.base = TEX.shown;
|
||||
TEX.rcOn = false;
|
||||
TEX.dirty = true;
|
||||
refresh();
|
||||
renderRecolor();
|
||||
}
|
||||
function cancelRecolor() {
|
||||
TEX.rcOn = false;
|
||||
refresh();
|
||||
renderRecolor();
|
||||
}
|
||||
let rcTimer = 0;
|
||||
function setRc(k, v) {
|
||||
TEX.rc[k] = v;
|
||||
TEX.rcOn = true;
|
||||
clearTimeout(rcTimer);
|
||||
rcTimer = setTimeout(() => {
|
||||
refresh();
|
||||
renderRecolorButtons();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// mouse
|
||||
// =====================================================================
|
||||
let drag = null;
|
||||
const toImage = (e) => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
return [(e.clientX - r.left - TEX.pan[0]) / TEX.zoom, (e.clientY - r.top - TEX.pan[1]) / TEX.zoom];
|
||||
};
|
||||
function bindCanvas() {
|
||||
cv.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
cv.addEventListener("mousedown", (e) => {
|
||||
if (!TEX.base) return;
|
||||
cv.focus();
|
||||
const [x, y] = toImage(e);
|
||||
const pan = e.button === 1 || e.button === 2 || TEX.tool === "pan" || spaceDown;
|
||||
if (pan) {
|
||||
drag = { pan: true, sx: e.clientX, sy: e.clientY, p0: TEX.pan.slice() };
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
const ix = Math.floor(x), iy = Math.floor(y);
|
||||
if (TEX.picking) {
|
||||
if (ix >= 0 && iy >= 0 && ix < TEX.w && iy < TEX.h) {
|
||||
const p = (iy * TEX.w + ix) * 4;
|
||||
TEX.picking = false;
|
||||
cv.classList.remove("pick");
|
||||
setRc("color", [TEX.base[p], TEX.base[p + 1], TEX.base[p + 2]]);
|
||||
renderRecolor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (TEX.tool === "wand" || TEX.tool === "color") {
|
||||
if (ix < 0 || iy < 0 || ix >= TEX.w || iy >= TEX.h) return;
|
||||
select(TK.wand(TEX.base, TEX.w, TEX.h, ix, iy, TEX.tol, TEX.tool === "wand" && TEX.contiguous), e);
|
||||
return;
|
||||
}
|
||||
drag = { tool: TEX.tool, pts: [[x, y]], shift: e.shiftKey, alt: e.altKey };
|
||||
if (TEX.tool === "rect") drag.pts = [[x, y], [x, y], [x, y], [x, y]];
|
||||
});
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!drag || !visible()) return;
|
||||
if (drag.pan) {
|
||||
TEX.pan = [drag.p0[0] + e.clientX - drag.sx, drag.p0[1] + e.clientY - drag.sy];
|
||||
TEX.fitted = false;
|
||||
return draw();
|
||||
}
|
||||
const [x, y] = toImage(e);
|
||||
if (drag.tool === "rect") {
|
||||
const [x0, y0] = drag.pts[0];
|
||||
drag.pts = [[x0, y0], [x, y0], [x, y], [x0, y]];
|
||||
} else {
|
||||
const last = drag.pts[drag.pts.length - 1];
|
||||
if (Math.hypot(x - last[0], y - last[1]) * TEX.zoom > 2) drag.pts.push([x, y]);
|
||||
}
|
||||
draw();
|
||||
});
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!drag) return;
|
||||
const d = drag;
|
||||
drag = null;
|
||||
if (d.pan) return;
|
||||
const pts = d.pts.map(([x, y]) => [Math.max(0, Math.min(TEX.w, x)), Math.max(0, Math.min(TEX.h, y))]);
|
||||
const piece = d.tool === "rect" ? TK.rect(TEX.w, TEX.h, pts[0][0], pts[0][1], pts[2][0], pts[2][1]) : TK.polygon(TEX.w, TEX.h, pts);
|
||||
if (TK.count(piece)) select(piece, { shiftKey: d.shift, altKey: d.alt });
|
||||
else draw();
|
||||
});
|
||||
cv.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
if (!TEX.base) return;
|
||||
e.preventDefault();
|
||||
const r = cv.getBoundingClientRect();
|
||||
const mx = e.clientX - r.left, my = e.clientY - r.top;
|
||||
const z0 = TEX.zoom, z1 = Math.max(0.05, Math.min(32, z0 * (e.deltaY < 0 ? 1.2 : 1 / 1.2)));
|
||||
TEX.pan = [mx - ((mx - TEX.pan[0]) * z1) / z0, my - ((my - TEX.pan[1]) * z1) / z0];
|
||||
TEX.zoom = z1;
|
||||
TEX.fitted = false;
|
||||
draw();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
cv.addEventListener("dragover", (e) => e.preventDefault());
|
||||
cv.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) openFile(bridge.pathOf(f));
|
||||
});
|
||||
}
|
||||
let spaceDown = false;
|
||||
const visible = () => $("view-tex").classList.contains("on");
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (!visible() || /input|select|textarea/i.test(document.activeElement?.tagName || "")) return;
|
||||
if (e.code === "Space") (spaceDown = true), e.preventDefault();
|
||||
const k = e.key.toLowerCase();
|
||||
if (e.ctrlKey && k === "z") (undo(!e.shiftKey), e.preventDefault());
|
||||
else if (e.ctrlKey && k === "y") (undo(false), e.preventDefault());
|
||||
else if (e.ctrlKey && k === "a") (selectAll("all"), e.preventDefault());
|
||||
else if (e.ctrlKey && k === "d") (selectAll("none"), e.preventDefault());
|
||||
else if (e.ctrlKey && e.shiftKey && k === "i") (selectAll("invert"), e.preventDefault());
|
||||
else if (e.ctrlKey && k === "s") (save(), e.preventDefault());
|
||||
else if (!e.ctrlKey && TOOLS.some((t) => t.key === k)) setTool(TOOLS.find((t) => t.key === k).id);
|
||||
else if (k === "enter" && TEX.rcOn) applyRecolor();
|
||||
else if (k === "escape") TEX.rcOn ? cancelRecolor() : TEX.picking ? ((TEX.picking = false), cv.classList.remove("pick")) : selectAll("none");
|
||||
else if (k === "0" && !e.ctrlKey) fitView();
|
||||
});
|
||||
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
|
||||
|
||||
// =====================================================================
|
||||
// side cards
|
||||
// =====================================================================
|
||||
const TOOLS = [
|
||||
{ id: "wand", key: "w", label: "Magic wand", tip: "Click: the area of similar colour around that point (W)" },
|
||||
{ id: "color", key: "c", label: "Colour range", tip: "Click: every pixel of similar colour in the picture (C)" },
|
||||
{ id: "lasso", key: "l", label: "Lasso", tip: "Drag around what to select (L)" },
|
||||
{ id: "rect", key: "m", label: "Rectangle", tip: "Drag a box (M)" },
|
||||
{ id: "pan", key: "h", label: "Hand", tip: "Drag to move the view (H) — or the middle / right button, or hold Space" },
|
||||
];
|
||||
function setTool(id) {
|
||||
TEX.tool = id;
|
||||
for (const b of $("tex-tools").children) b.classList.toggle("on", b.dataset.tool === id);
|
||||
cv?.setAttribute("data-tool", id);
|
||||
renderSel();
|
||||
}
|
||||
|
||||
function renderSide() {
|
||||
renderSel();
|
||||
renderRecolor();
|
||||
renderExport();
|
||||
}
|
||||
|
||||
function renderSel() {
|
||||
const box = $("tex-sel");
|
||||
const n = TEX.raw ? TK.count(TEX.raw) : 0;
|
||||
const num = (value, min, max, onchange) => el("input", { type: "number", min, max, step: "1", value: String(value), onchange: (e) => onchange(Math.max(min, Math.min(max, Math.round(Number(e.target.value) || 0)))) });
|
||||
fill(box,
|
||||
el("div", { class: "ans-grid" }, [
|
||||
...(TEX.tool === "wand" || TEX.tool === "color"
|
||||
? field("Tolerance", el("div", { class: "ans-vec" }, [
|
||||
el("input", { type: "range", min: "0", max: "128", value: String(TEX.tol), class: "grow", oninput: (e) => (($("tex-tol-v").textContent = e.target.value), (TEX.tol = Number(e.target.value))) }),
|
||||
el("span", { id: "tex-tol-v", class: "mono", text: String(TEX.tol) }),
|
||||
]))
|
||||
: []),
|
||||
...field("Close gaps", num(TEX.close, 0, 16, (v) => ((TEX.close = v), refresh()))),
|
||||
...field("Feather", num(TEX.feather, 0, 16, (v) => ((TEX.feather = v), refresh()))),
|
||||
]),
|
||||
TEX.tool === "wand" ? el("label", { class: "ans-check", title: "Off: like Colour range (the colour anywhere in the picture)" }, [el("input", { type: "checkbox", checked: TEX.contiguous, onchange: (e) => (TEX.contiguous = e.target.checked) }), "Only touching pixels"]) : null,
|
||||
el("div", { class: "ans-row" }, [
|
||||
el("button", { class: "btn", text: "All", title: "Ctrl+A", onclick: () => selectAll("all") }),
|
||||
el("button", { class: "btn", text: "None", title: "Ctrl+D / Esc", onclick: () => selectAll("none") }),
|
||||
el("button", { class: "btn", text: "Invert", title: "Ctrl+Shift+I", onclick: () => selectAll("invert") }),
|
||||
el("button", { class: "btn", text: "Grow", title: "+2 px", onclick: () => selectAll(2) }),
|
||||
el("button", { class: "btn", text: "Shrink", title: "−2 px", onclick: () => selectAll(-2) }),
|
||||
]),
|
||||
el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: TEX.showSel, onchange: (e) => ((TEX.showSel = e.target.checked), draw()) }), "Show the selection"]),
|
||||
el("div", { class: "ans-note", text: n ? `${n.toLocaleString()} pixels selected (${((100 * n) / (TEX.w * TEX.h)).toFixed(1)}%). Shift + click / drag adds, Alt takes away.` : "Nothing selected: a recolour changes the whole picture. Shift + click / drag adds to a selection, Alt takes away. Close gaps fills the dark strands a colour pick misses." }),
|
||||
);
|
||||
}
|
||||
|
||||
function slider(label, k, min, max, scale = 1, unit = "") {
|
||||
const out = el("span", { class: "mono tex-val", text: `${Math.round(TEX.rc[k] * scale)}${unit}` });
|
||||
return field(label, el("div", { class: "ans-vec" }, [
|
||||
el("input", { type: "range", min: String(min), max: String(max), value: String(Math.round(TEX.rc[k] * scale)), class: "grow", oninput: (e) => {
|
||||
out.textContent = `${e.target.value}${unit}`;
|
||||
setRc(k, Number(e.target.value) / scale);
|
||||
}, ondblclick: (e) => {
|
||||
// back to the start value
|
||||
const v = k === "strength" ? 1 : 0;
|
||||
e.target.value = String(Math.round(v * scale));
|
||||
out.textContent = `${Math.round(v * scale)}${unit}`;
|
||||
setRc(k, v);
|
||||
} }),
|
||||
out,
|
||||
]));
|
||||
}
|
||||
|
||||
const SWATCHES = [[34, 32, 32], [150, 145, 128], [235, 190, 90], [130, 95, 35], [105, 38, 14], [190, 20, 20], [72, 10, 28], [20, 40, 120], [90, 30, 120], [20, 110, 60], [140, 145, 155], [240, 150, 190]];
|
||||
function renderRecolor() {
|
||||
const r = TEX.rc;
|
||||
const box = $("tex-rc");
|
||||
const mode = el("div", { class: "seg" }, [["colorize", "Colourise"], ["adjust", "Hue / Sat / Light"]].map(([v, t]) =>
|
||||
el("button", { class: r.mode === v ? "on" : "", text: t, onclick: () => (setRc("mode", v), renderRecolor()) }),
|
||||
));
|
||||
const color = el("input", { type: "color", value: hex(r.color), oninput: (e) => setRc("color", unhex(e.target.value)) });
|
||||
fill(box,
|
||||
mode,
|
||||
r.mode === "colorize"
|
||||
? el("div", {}, [
|
||||
el("div", { class: "ans-grid" }, [
|
||||
...field("Colour", el("div", { class: "ans-vec" }, [
|
||||
color,
|
||||
el("button", { class: `iconbtn only${TEX.picking ? " on" : ""}`, title: "Take the colour from the picture (click it)", onclick: () => ((TEX.picking = !TEX.picking), cv.classList.toggle("pick", TEX.picking), renderRecolor()) }, [icon("target")]),
|
||||
])),
|
||||
...slider("Strength", "strength", 0, 100, 100, "%"),
|
||||
...slider("Brightness", "bright", -100, 100, 100),
|
||||
...slider("Contrast", "contrast", -100, 100, 100),
|
||||
]),
|
||||
el("div", { class: "tex-swatches" }, SWATCHES.map((c) => el("button", { class: "tex-sw", title: hex(c), style: `background:${hex(c)}`, onclick: () => (setRc("color", c), renderRecolor()) }))),
|
||||
el("div", { class: "ans-note", text: "Each pixel keeps its light and dark (strands, shading) in the new colour: made for dark or brown hair." }),
|
||||
])
|
||||
: el("div", { class: "ans-grid" }, [...slider("Hue", "hue", -180, 180, 1, "°"), ...slider("Saturation", "sat", -100, 100, 100), ...slider("Lightness", "light", -100, 100, 100), ...slider("Contrast", "contrast", -100, 100, 100)]),
|
||||
el("div", { id: "tex-rc-btns", class: "ans-row" }),
|
||||
);
|
||||
renderRecolorButtons();
|
||||
}
|
||||
function renderRecolorButtons() {
|
||||
const b = $("tex-rc-btns");
|
||||
if (!b) return;
|
||||
b.replaceChildren(
|
||||
el("button", { class: "btn primary", text: "Apply", title: "Enter", disabled: !TEX.rcOn || !TEX.base, onclick: applyRecolor }),
|
||||
el("button", { class: "btn", text: "Cancel", title: "Esc", disabled: !TEX.rcOn, onclick: cancelRecolor }),
|
||||
el("span", { class: "grow" }),
|
||||
el("button", { class: "iconbtn only", title: "Undo (Ctrl+Z)", disabled: !TEX.undo.length, onclick: () => undo(true) }, [icon("undo")]),
|
||||
el("button", { class: "iconbtn only", title: "Redo (Ctrl+Y)", disabled: !TEX.redo.length, onclick: () => undo(false) }, [icon("redo")]),
|
||||
);
|
||||
}
|
||||
|
||||
const FORMATS = [
|
||||
["auto", "Auto (DXT1, DXT5 with alpha)"],
|
||||
["DXT1", "DXT1 — no / cut-out alpha (smallest)"],
|
||||
["DXT3", "DXT3 — sharp alpha"],
|
||||
["DXT5", "DXT5 — smooth alpha"],
|
||||
["ARGB", "A8R8G8B8 — uncompressed (4× bigger)"],
|
||||
];
|
||||
function outSize() {
|
||||
let w = TEX.w, h = TEX.h;
|
||||
if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) {
|
||||
const f = TEX.exp.max / Math.max(w, h);
|
||||
(w = Math.max(1, Math.round(w * f))), (h = Math.max(1, Math.round(h * f)));
|
||||
}
|
||||
// the game's cards want powers of two
|
||||
return [TK.pow2(w), TK.pow2(h)];
|
||||
}
|
||||
function renderExport() {
|
||||
const box = $("tex-exp");
|
||||
if (!TEX.base) return box.replaceChildren(el("div", { class: "ans-note", text: "Open a picture." }));
|
||||
const e = TEX.exp;
|
||||
const [ow, oh] = outSize();
|
||||
const fmt = TK.pickFormat(TEX.base, e.format);
|
||||
fill(box,
|
||||
el("div", { class: "ans-grid" }, [
|
||||
...field("Format", el("select", { onchange: (ev) => ((e.format = ev.target.value), renderExport()) }, FORMATS.map(([v, t]) => el("option", { value: v, text: t, selected: v === e.format })))),
|
||||
...field("Size", el("select", { onchange: (ev) => ((e.max = Number(ev.target.value)), renderExport()) }, [[0, "Keep"], [2048, "≤ 2048"], [1024, "≤ 1024"], [512, "≤ 512"], [256, "≤ 256"], [128, "≤ 128"]].map(([v, t]) => el("option", { value: v, text: t, selected: v === e.max })))),
|
||||
]),
|
||||
el("label", { class: "ans-check", title: "Smaller copies for far away (what most of the game's textures have)" }, [el("input", { type: "checkbox", checked: e.mips, onchange: (ev) => ((e.mips = ev.target.checked), renderExport()) }), "Mip maps"]),
|
||||
el("div", { class: "ans-note", text: `Saves ${ow}×${oh} ${fmt === "ARGB" ? "A8R8G8B8" : fmt}${e.mips ? " + mip maps" : ""}${ow !== TEX.w || oh !== TEX.h ? ` (resized from ${TEX.w}×${TEX.h}${TK.pow2(TEX.w) !== TEX.w || TK.pow2(TEX.h) !== TEX.h ? ": the game wants powers of two" : ""})` : ""}. Transparent black in DXT1 / alpha 0 is where the game shows the skin colour (#MODEL_COLOR).` }),
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// saving
|
||||
// =====================================================================
|
||||
/** The current pixels (recolour applied if previewed) as .dds bytes with the export settings. */
|
||||
function ddsBytes(px = TEX.rcOn ? TEX.shown : TEX.base, w = TEX.w, h = TEX.h) {
|
||||
const [ow, oh] = TEX.base && px === (TEX.rcOn ? TEX.shown : TEX.base) ? outSize() : [TK.pow2(w), TK.pow2(h)];
|
||||
const p = ow !== w || oh !== h ? TK.resize(px, w, h, ow, oh) : px;
|
||||
return TK.encodeDds(p, ow, oh, { format: TEX.exp.format, mips: TEX.exp.mips });
|
||||
}
|
||||
|
||||
async function confirmOverwrite(names) {
|
||||
if (!names.length) return true;
|
||||
return modal("Replace files?", [el("p", { text: `${names.length} file(s) already exist and will be replaced:` }), el("div", { class: "mono ans-note" }, names.map((n) => el("div", { text: n })))], [
|
||||
{ label: "Cancel", value: false },
|
||||
{ label: "Replace", kind: "danger", value: true },
|
||||
]);
|
||||
}
|
||||
|
||||
async function writeDds(folder, name) {
|
||||
if (TEX.rcOn) applyRecolor();
|
||||
loading(true, `Encoding ${name}…`);
|
||||
await frame();
|
||||
try {
|
||||
const r = ddsBytes();
|
||||
await call("mdl:writeFiles", folder, [{ name, data: r.bytes }]);
|
||||
TEX.dirty = false;
|
||||
refresh();
|
||||
toast(`Saved ${name} (${r.format === "ARGB" ? "A8R8G8B8" : r.format}, ${r.levels} level${r.levels > 1 ? "s" : ""}, ${size(r.bytes.length)}).`);
|
||||
if (folder === TEX.dir) await listFiles();
|
||||
} catch (err) {
|
||||
toast(`Save: ${err.message}`, true);
|
||||
} finally {
|
||||
loading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Save: <name>.dds next to the picture (a .dds is replaced). */
|
||||
async function save() {
|
||||
if (!TEX.base) return;
|
||||
const name = `${TEX.file.stem}.dds`;
|
||||
const exists = TEX.file.ext === "dds" || (TEX.file.dir === TEX.dir && TEX.files.some((f) => f.name.toLowerCase() === name.toLowerCase()));
|
||||
if (exists && !(await confirmOverwrite([name]))) return;
|
||||
await writeDds(TEX.file.dir, name);
|
||||
}
|
||||
|
||||
async function saveAs() {
|
||||
if (!TEX.base) return;
|
||||
const kind = await modal("Save as", [el("p", { text: "Save the picture as:" })], [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "PNG", value: "png" },
|
||||
{ label: "DDS", kind: "primary", value: "dds" },
|
||||
]);
|
||||
if (!kind) return;
|
||||
const file = await call("dialog:saveAs", `${TEX.file.stem}.${kind}`, kind);
|
||||
if (!file) return;
|
||||
const folder = file.replace(/[\\/][^\\/]*$/, ""), name = file.replace(/\\/g, "/").split("/").pop();
|
||||
if (kind === "dds") return writeDds(folder, /\.dds$/i.test(name) ? name : `${name}.dds`);
|
||||
if (TEX.rcOn) applyRecolor();
|
||||
const c = new OffscreenCanvas(TEX.w, TEX.h);
|
||||
c.getContext("2d").putImageData(new ImageData(TEX.base, TEX.w, TEX.h), 0, 0);
|
||||
const blob = await c.convertToBlob({ type: "image/png" });
|
||||
const b64 = await new Promise((res) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => res(String(fr.result).split(",")[1]);
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
await call("file:writeBase64", file, b64);
|
||||
toast(`Saved ${name}.`);
|
||||
if (folder === TEX.dir) listFiles();
|
||||
}
|
||||
|
||||
/** Every picture of the folder that isn't a .dds → <name>.dds with the export settings. */
|
||||
async function convertFolder() {
|
||||
if (!TEX.dir) return openFolder();
|
||||
const src = TEX.files.filter((f) => extOf(f.name) !== "dds");
|
||||
if (!src.length) return toast("No .png / .jpg / .tga / .bmp in this folder.", true);
|
||||
const have = new Set(TEX.files.map((f) => f.name.toLowerCase()));
|
||||
const list = el("div", { class: "tex-conv" }, src.map((f) => el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: true, "data-name": f.name }), `${f.name} → ${stem(f.name)}.dds${have.has(`${stem(f.name)}.dds`.toLowerCase()) ? " (replaces)" : ""}`])));
|
||||
const fmt = FORMATS.find(([v]) => v === TEX.exp.format)[1];
|
||||
const ok = await modal("Convert to .dds", [
|
||||
el("p", { text: `Into ${TEX.dir}, as ${fmt}${TEX.exp.mips ? " with mip maps" : ""}${TEX.exp.max ? `, at most ${TEX.exp.max}` : ""} (sizes to powers of two). Change it in Save as .dds.` }),
|
||||
list,
|
||||
], [{ label: "Cancel", value: false }, { label: "Convert", kind: "primary", value: true }]);
|
||||
if (!ok) return;
|
||||
const names = [...list.querySelectorAll("input:checked")].map((i) => i.dataset.name);
|
||||
let done = 0;
|
||||
loading(true, "Converting…");
|
||||
try {
|
||||
for (const n of names) {
|
||||
$("tex-loading").querySelector("span").textContent = `Converting ${n} (${done + 1} / ${names.length})…`;
|
||||
await frame();
|
||||
const { bytes } = await call("mdl:readFile", join(TEX.dir, n));
|
||||
const bmp = await TexDecode.decode(bytes, extOf(n));
|
||||
const c = new OffscreenCanvas(bmp.width, bmp.height);
|
||||
const cx = c.getContext("2d", { willReadFrequently: true });
|
||||
cx.drawImage(bmp, 0, 0);
|
||||
let px = cx.getImageData(0, 0, bmp.width, bmp.height).data, w = bmp.width, h = bmp.height;
|
||||
if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) {
|
||||
const f = TEX.exp.max / Math.max(w, h);
|
||||
const nw = Math.max(1, Math.round(w * f)), nh = Math.max(1, Math.round(h * f));
|
||||
(px = TK.resize(px, w, h, nw, nh)), (w = nw), (h = nh);
|
||||
}
|
||||
const r = ddsBytes(px, w, h);
|
||||
await call("mdl:writeFiles", TEX.dir, [{ name: `${stem(n)}.dds`, data: r.bytes }]);
|
||||
done++;
|
||||
}
|
||||
toast(`Converted ${done} file(s) to .dds.`);
|
||||
} catch (err) {
|
||||
toast(`Convert: ${err.message} (${done} done)`, true);
|
||||
} finally {
|
||||
loading(false);
|
||||
listFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// start
|
||||
// =====================================================================
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
cv = $("tex-canvas");
|
||||
g = cv.getContext("2d");
|
||||
bindCanvas();
|
||||
new ResizeObserver(() => (TEX.fitted ? fitView() : draw())).observe(cv);
|
||||
$("tex-tools").replaceChildren(...TOOLS.map((t) => el("button", { "data-tool": t.id, title: `${t.label}: ${t.tip}`, text: t.label, onclick: () => setTool(t.id) })));
|
||||
setTool("wand");
|
||||
$("tex-open").onclick = () => openFolder();
|
||||
$("tex-openfile").onclick = async () => {
|
||||
const f = await call("dialog:openExt", "Open a picture", "Pictures", ["png", "jpg", "jpeg", "tga", "bmp", "dds"]);
|
||||
if (!f) return;
|
||||
const dir = f.replace(/[\\/][^\\/]*$/, "");
|
||||
if (dir !== TEX.dir) await openFolder(dir);
|
||||
openFile(f);
|
||||
};
|
||||
$("tex-refresh").onclick = () => listFiles();
|
||||
$("tex-convert").onclick = () => convertFolder();
|
||||
$("tex-save").onclick = () => save();
|
||||
$("tex-saveas").onclick = () => saveAs();
|
||||
$("tex-fit").onclick = () => fitView();
|
||||
$("tex-100").onclick = () => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
TEX.zoom = 1;
|
||||
TEX.fitted = false;
|
||||
TEX.pan = [(r.width - TEX.w) / 2, (r.height - TEX.h) / 2];
|
||||
draw();
|
||||
};
|
||||
$("tex-search").addEventListener("input", renderList);
|
||||
renderSide();
|
||||
let dir = null;
|
||||
try {
|
||||
dir = localStorage.getItem(KEY);
|
||||
} catch {}
|
||||
if (dir) openFolder(dir);
|
||||
else renderList();
|
||||
}
|
||||
|
||||
// the tab opens: set up once
|
||||
document.querySelector('#tabs button[data-view="tex"]')?.addEventListener("click", () => setTimeout(start, 0));
|
||||
Object.assign(TEX, { openFolder, openFile, save, refresh, selectAll, applyRecolor, setRc, convertFolder, start });
|
||||
})();
|
||||
@@ -0,0 +1,671 @@
|
||||
"use strict";
|
||||
|
||||
// UI resources and drawing for the window editor, following the client (io3DEngine ioGUIManager,
|
||||
// ioUIRenderImage, ioUIRenderFrame, ioUITitle, ioButton, ioStringManager):
|
||||
// UIRes imagesets (xml/uiimageset.xml), frame templates (xml/defaultuiframe.xml), textures,
|
||||
// the UI texts (text/text.txt) and the game font (font/lostsaga.ttf)
|
||||
// UIText STR(n) → text ("xml_<file>_<n>"), CP949 encoding
|
||||
// UIDraw one window: frames, images, title / texts, button states, as the client draws them
|
||||
// UIProps inputs for attributes (colours, alignments…) and the quick form of a window
|
||||
|
||||
const UIRes = (() => {
|
||||
let src = null;
|
||||
let sets = new Map(); // imageset name (lower) → { file, images: Map(name lower → {x,y,w,h,ox,oy}) }
|
||||
let frames = new Map(); // template name (lower) → { name, parts: [{ type, w, h, set, image }] }
|
||||
const tex = new Map(); // texture file → Promise<ImageBitmap | null>
|
||||
const imgCache = new Map();
|
||||
let texts = null; // { encoding, xml, source }
|
||||
let fontFamily = "";
|
||||
|
||||
const toLatin1 = (u8) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192));
|
||||
return s;
|
||||
};
|
||||
|
||||
function reset() {
|
||||
src = null;
|
||||
sets = new Map();
|
||||
frames = new Map();
|
||||
tex.clear();
|
||||
imgCache.clear();
|
||||
texts = null;
|
||||
}
|
||||
|
||||
async function load(s) {
|
||||
if (src && JSON.stringify(src) === JSON.stringify(s)) return;
|
||||
reset();
|
||||
src = s;
|
||||
if (!s) return;
|
||||
const [is, fr, tx, font] = await Promise.all([
|
||||
call("mdl:readPath", s, "xml/uiimageset.xml").catch(() => null),
|
||||
call("mdl:readPath", s, "xml/defaultuiframe.xml").catch(() => null),
|
||||
call("wnd:texts", s).catch(() => null),
|
||||
fontFamily ? null : call("wnd:font", s).catch(() => null),
|
||||
]);
|
||||
if (is) {
|
||||
const t = toLatin1(is.bytes);
|
||||
for (const m of t.matchAll(/<Imageset\b([^>]*)>([\s\S]*?)<\/Imageset>/gi)) {
|
||||
const a = attrs(m[1]);
|
||||
const images = new Map();
|
||||
for (const im of m[2].matchAll(/<Image\b([^>]*)\/?>/gi)) {
|
||||
const b = attrs(im[1]);
|
||||
images.set((b.name || "").toLowerCase(), { x: +b.x || 0, y: +b.y || 0, w: +b.width || 0, h: +b.height || 0, ox: +b.offsetx || 0, oy: +b.offsety || 0 });
|
||||
}
|
||||
sets.set((a.name || "").toLowerCase(), { file: a.file || "", images });
|
||||
}
|
||||
}
|
||||
if (fr) {
|
||||
const t = toLatin1(fr.bytes);
|
||||
for (const m of t.matchAll(/<FrameComponent\b([^>]*)>([\s\S]*?)<\/FrameComponent>/gi)) {
|
||||
const a = attrs(m[1]);
|
||||
const parts = [];
|
||||
for (const im of m[2].matchAll(/<Image\b([^>]*)\/?>/gi)) {
|
||||
const b = attrs(im[1]);
|
||||
const [w, h] = String(b.size || "").split(".").map(Number);
|
||||
parts.push({ type: (b.type || "").toLowerCase(), w: w || 0, h: h || 0, set: b.imageset || "", image: b.image || "" });
|
||||
}
|
||||
frames.set((a.name || "").toLowerCase(), { name: a.name, parts });
|
||||
}
|
||||
}
|
||||
texts = tx;
|
||||
if (font && !fontFamily) {
|
||||
try {
|
||||
const face = new FontFace("LostSagaUI", font.bytes);
|
||||
await face.load();
|
||||
document.fonts.add(face);
|
||||
fontFamily = "LostSagaUI";
|
||||
} catch (err) {
|
||||
console.warn("[wnd] font", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
function attrs(s) {
|
||||
const o = {};
|
||||
for (const m of s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) o[m[1].toLowerCase()] = m[2];
|
||||
return o;
|
||||
}
|
||||
|
||||
function texture(file) {
|
||||
const key = file.toLowerCase();
|
||||
if (!tex.has(key)) {
|
||||
tex.set(
|
||||
key,
|
||||
(async () => {
|
||||
if (!src) return null;
|
||||
const t = await call("mdl:texture", src, file);
|
||||
if (!t) return null;
|
||||
try {
|
||||
return await TexDecode.decode(t.bytes, t.ext);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return tex.get(key);
|
||||
}
|
||||
|
||||
/** An imageset image (or a whole texture with File=): { bmp, x, y, w, h, ox, oy }. */
|
||||
async function image(set, name, file) {
|
||||
const key = file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase();
|
||||
if (imgCache.has(key)) return imgCache.get(key);
|
||||
if (file) {
|
||||
const bmp = await texture(file);
|
||||
const v = bmp ? { bmp, x: 0, y: 0, w: bmp.width, h: bmp.height, ox: 0, oy: 0 } : null;
|
||||
imgCache.set(key, v);
|
||||
return v;
|
||||
}
|
||||
const s = sets.get((set || "").toLowerCase());
|
||||
const r = s?.images.get((name || "").toLowerCase());
|
||||
if (!s || !r) {
|
||||
imgCache.set(key, null);
|
||||
return null;
|
||||
}
|
||||
const bmp = await texture(s.file);
|
||||
const v = bmp ? { bmp, ...r } : null;
|
||||
imgCache.set(key, v);
|
||||
return v;
|
||||
}
|
||||
const imageSync = (set, name, file) => imgCache.get(file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase());
|
||||
const frame = (name) => frames.get((name || "").toLowerCase()) || null;
|
||||
const imagesets = () => [...sets.keys()];
|
||||
const imagesOf = (set) => [...(sets.get((set || "").toLowerCase())?.images.keys() || [])];
|
||||
const frameNames = () => [...frames.values()].map((f) => f.name);
|
||||
|
||||
return { reset, load, image, imageSync, frame, imagesets, imagesOf, frameNames, texts: () => texts, font: () => fontFamily };
|
||||
})();
|
||||
window.UIRes = UIRes;
|
||||
|
||||
const UIText = (() => {
|
||||
// CP949 encoder: the reverse of the browser's euc-kr decoder, built once
|
||||
let table = null;
|
||||
function build() {
|
||||
table = new Map();
|
||||
const dec = new TextDecoder("euc-kr");
|
||||
const b = new Uint8Array(2);
|
||||
for (let hi = 0x81; hi <= 0xfe; hi++) {
|
||||
for (let lo = 0x41; lo <= 0xfe; lo++) {
|
||||
b[0] = hi;
|
||||
b[1] = lo;
|
||||
const ch = dec.decode(b);
|
||||
if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, [hi, lo]);
|
||||
}
|
||||
}
|
||||
}
|
||||
function encodeCp949(str) {
|
||||
if (!table) build();
|
||||
const out = [];
|
||||
for (const ch of str) {
|
||||
const c = ch.codePointAt(0);
|
||||
if (c < 0x80) out.push(c);
|
||||
else {
|
||||
const t = table.get(ch);
|
||||
if (!t) throw new Error(`“${ch}” can't be written in CP949 (Korean encoding of the client files).`);
|
||||
out.push(t[0], t[1]);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
let fileKey = ""; // xml file name without folder / extension, lowercase (ioGUIManager::AddWnd)
|
||||
const setFile = (name) => (fileKey = (name || "").replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "").toLowerCase());
|
||||
const key = (n) => `xml_${fileKey}_${n}`;
|
||||
|
||||
/** A Title / Text / Tooltip value as the client shows it: STR(n) → text.txt "xml_<file>_<n>". */
|
||||
function resolve(doc, el, text) {
|
||||
const t = (text || "").trim();
|
||||
const m = /STR\((\d+)/i.exec(t);
|
||||
const decoded = LSF.decode(Uint8Array.from(t, (c) => c.charCodeAt(0) & 255));
|
||||
if (!m) return decoded;
|
||||
const tx = UIRes.texts();
|
||||
return tx?.xml[key(m[1])] ?? key(m[1]); // a missing key shows the key, like the client
|
||||
}
|
||||
function titleOf(doc, w) {
|
||||
const title = XmlDoc.child(w, "Title");
|
||||
const te = title && XmlDoc.child(title, "Text");
|
||||
return te ? resolve(doc, te, XmlDoc.text(doc, te)) : "";
|
||||
}
|
||||
return { encodeCp949, resolve, titleOf, setFile, key };
|
||||
})();
|
||||
window.UIText = UIText;
|
||||
|
||||
const UIDraw = (() => {
|
||||
const X = XmlDoc;
|
||||
const low = (s) => (s || "").toLowerCase();
|
||||
|
||||
/** AARRGGBB like ioStringConverter: missing → FF000000, a value of 0 → FFFFFFFF. */
|
||||
function colorOf(s, dflt = "FF000000") {
|
||||
if (s === undefined || s === null || s === "") s = dflt;
|
||||
let v = parseInt(String(s).replace(/^0x/i, ""), 16);
|
||||
if (!isFinite(v)) v = parseInt(dflt, 16);
|
||||
if (v === 0) v = 0xffffffff;
|
||||
if (String(s).replace(/^0x/i, "").length <= 6) v = (0xff000000 | v) >>> 0;
|
||||
return { a: ((v >>> 24) & 255) / 255, r: (v >>> 16) & 255, g: (v >>> 8) & 255, b: v & 255 };
|
||||
}
|
||||
const css = (c, alpha = 1) => `rgba(${c.r},${c.g},${c.b},${(c.a * alpha).toFixed(3)})`;
|
||||
|
||||
const BLEND = { add: "lighter", addalpha: "lighter", addcolor: "lighter", screen: "screen", multiply: "multiply", lighten: "lighten", darken: "darken" };
|
||||
function withBlend(ctx, renderType, fn) {
|
||||
const rt = low(renderType);
|
||||
ctx.save();
|
||||
if (BLEND[rt]) ctx.globalCompositeOperation = BLEND[rt];
|
||||
if (rt === "gray" || rt === "graylight") ctx.filter = "grayscale(1)";
|
||||
fn();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// tinted copies (Color on an image / frame multiplies RGB, keeps alpha)
|
||||
const tintCache = new Map();
|
||||
function tinted(im, color) {
|
||||
if (!color || /^f{6}$/i.test(color.slice(-6))) return im;
|
||||
const k = `${im.x},${im.y},${im.w},${im.h},${color}`;
|
||||
let t = tintCache.get(im.bmp)?.get(k);
|
||||
if (t) return t;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = im.w;
|
||||
c.height = im.h;
|
||||
const g = c.getContext("2d");
|
||||
g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h);
|
||||
g.globalCompositeOperation = "multiply";
|
||||
const col = colorOf(color, "FFFFFFFF");
|
||||
g.fillStyle = `rgb(${col.r},${col.g},${col.b})`;
|
||||
g.fillRect(0, 0, im.w, im.h);
|
||||
g.globalCompositeOperation = "destination-in";
|
||||
g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h);
|
||||
t = { ...im, bmp: c, x: 0, y: 0 };
|
||||
if (!tintCache.has(im.bmp)) tintCache.set(im.bmp, new Map());
|
||||
tintCache.get(im.bmp).set(k, t);
|
||||
return t;
|
||||
}
|
||||
|
||||
function blit(ctx, im, x, y, w, h, flipH, flipV) {
|
||||
if (!im || !im.bmp || !im.w || !im.h || w <= 0 || h <= 0) return;
|
||||
if (!flipH && !flipV) return ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, x, y, w, h);
|
||||
ctx.save();
|
||||
ctx.translate(x + (flipH ? w : 0), y + (flipV ? h : 0));
|
||||
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
|
||||
ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* A window <Image> (ioUIRenderImage): Area gives the render rect relative to the window (only
|
||||
* the position when W or H is 0); x = win + Area.X − imageset OffsetX (+ −w/2 Center, −w Right);
|
||||
* natural size except Stretch; Tile repeats; Reverse flips; Scale scales; Color tints.
|
||||
*/
|
||||
function drawImageEl(ctx, e, r) {
|
||||
const im0 = UIRes.imageSync(X.attr(e, "Imageset"), X.attr(e, "Image"), X.attr(e, "File"));
|
||||
if (!im0) return;
|
||||
const im = tinted(im0, X.attr(e, "Color"));
|
||||
const area = X.child(e, "Area");
|
||||
const scale = X.num(e, "Scale", 1) || 1;
|
||||
const aw = X.num(area, "Width"), ah = X.num(area, "Height");
|
||||
const hasRect = aw > 0 && ah > 0;
|
||||
const ha = low(X.attr(area, "HorzAlign") || "left"), va = low(X.attr(area, "VertAlign") || "top");
|
||||
const nw = im.w * scale, nh = im.h * scale;
|
||||
const bx = r.x + X.num(area, "X") - im.ox * scale, by = r.y + X.num(area, "Y") - im.oy * scale;
|
||||
const rev = low(X.attr(area, "Reverse"));
|
||||
const fh = rev.includes("horz"), fv = rev.includes("vert");
|
||||
const w = hasRect && ha === "stretch" ? aw : nw;
|
||||
const h = hasRect && va === "stretch" ? ah : nh;
|
||||
let x = bx + (ha === "center" ? -w / 2 : ha === "right" ? -w : 0);
|
||||
let y = by + (va === "center" ? -h / 2 : va === "bottom" ? -h : 0);
|
||||
withBlend(ctx, X.attr(area, "RenderType"), () => {
|
||||
const tx = hasRect && ha === "tile" ? Math.max(1, Math.ceil(aw / nw)) : 1;
|
||||
const ty = hasRect && va === "tile" ? Math.max(1, Math.ceil(ah / nh)) : 1;
|
||||
for (let i = 0; i < tx; i++) for (let j = 0; j < ty; j++) blit(ctx, im, x + i * w, y + j * h, w, h, fh, fv);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame template (ioUIRenderFrame) inside a rect: corners at their image size, the part Size
|
||||
* ("w.h") sets the layout thickness, edges stretched along, centre stretched both ways.
|
||||
*/
|
||||
function drawFrame(ctx, tpl, r, color, renderType) {
|
||||
const f = UIRes.frame(tpl);
|
||||
if (!f) return false;
|
||||
const p = Object.fromEntries(f.parts.map((x) => [x.type, x]));
|
||||
const img = (x) => {
|
||||
const im = x && UIRes.imageSync(x.set, x.image);
|
||||
return im ? tinted(im, color) : null;
|
||||
};
|
||||
const W = (x) => (x ? x.w || img(x)?.w || 0 : 0);
|
||||
const H = (x) => (x ? x.h || img(x)?.h || 0 : 0);
|
||||
const nat = (x) => img(x) || { w: 0, h: 0 };
|
||||
const fw = r.w, fh = r.h;
|
||||
withBlend(ctx, renderType, () => {
|
||||
const put = (x, px, py, pw, ph) => {
|
||||
const im = img(x);
|
||||
if (im && pw > 0 && ph > 0) blit(ctx, im, r.x + px - (im.ox || 0), r.y + py - (im.oy || 0), pw, ph);
|
||||
};
|
||||
const tl = p.topleft, tr = p.topright, bl = p.bottomleft, br = p.bottomright;
|
||||
put(tl, 0, 0, nat(tl).w, nat(tl).h);
|
||||
put(p.topedge, W(tl), 0, fw - W(tl) - W(tr), nat(p.topedge).h);
|
||||
put(tr, fw - W(tr), 0, nat(tr).w, nat(tr).h);
|
||||
put(p.leftedge, 0, H(tl), nat(p.leftedge).w, fh - H(tl) - H(bl));
|
||||
put(p.center, W(p.leftedge), H(p.topedge), fw - W(p.leftedge) - W(p.rightedge), fh - H(p.topedge) - H(p.bottomedge));
|
||||
put(p.rightedge, fw - W(p.rightedge), H(tr), nat(p.rightedge).w, fh - H(tr) - H(br));
|
||||
put(bl, 0, fh - H(bl), nat(bl).w, nat(bl).h);
|
||||
put(p.bottomedge, W(bl), fh - H(p.bottomedge), fw - W(bl) - W(br), nat(p.bottomedge).h);
|
||||
put(br, fw - W(br), fh - H(br), nat(br).w, nat(br).h);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawFrameEl(ctx, e, r) {
|
||||
const area = X.child(e, "Area");
|
||||
// no Width / Height: to the window's right / bottom edge (e.g. a BottomBar at Y=193)
|
||||
const ax = X.num(area, "X"), ay = X.num(area, "Y");
|
||||
const ar = area ? { x: r.x + ax, y: r.y + ay, w: X.num(area, "Width") || Math.max(0, r.w - ax), h: X.num(area, "Height") || Math.max(0, r.h - ay) } : r;
|
||||
const tpl = X.attr(e, "Templete") || X.attr(e, "Template");
|
||||
// "…multiply…" templates / MultiplyFrm are the darkening strips the client draws with Multiply
|
||||
const rt = X.attr(area, "RenderType") || (/multiply/i.test(tpl || "") || /multiply/i.test(X.attr(e, "Type") || "") ? "Multiply" : null);
|
||||
drawFrame(ctx, tpl, ar, X.attr(e, "Color"), rt);
|
||||
}
|
||||
|
||||
// ---------- text (ioUITitle + ioFontManager) ----------
|
||||
function fontCss(size, bold) {
|
||||
const fam = UIRes.font();
|
||||
return `${bold ? "bold " : ""}${Math.max(1, size)}px ${fam ? `"${fam}", ` : ""}"Microsoft JhengHei", "Malgun Gothic", "Leelawadee UI", sans-serif`;
|
||||
}
|
||||
/** Draws one text with the client's styles (outline / shadow in the Back colour). */
|
||||
function drawText(ctx, str, { x, y, size = 12, style = "Normal", halign = "left", valign = "top", gap = 0, text, back, alpha = 1, widthCut = 0 }) {
|
||||
if (!str) return;
|
||||
const st = low(style);
|
||||
const bold = st.startsWith("bold");
|
||||
ctx.save();
|
||||
ctx.font = fontCss(size, bold);
|
||||
ctx.textAlign = halign === "center" ? "center" : halign === "right" ? "right" : "left";
|
||||
ctx.textBaseline = "top";
|
||||
const lines = String(str).split(/#|\n/);
|
||||
const pitch = gap || 0; // VertGap is 0 when missing (the client overlaps un-gapped lines)
|
||||
const totalH = size + pitch * (lines.length - 1);
|
||||
let y0 = valign === "center" ? y - totalH / 2 : valign === "bottom" ? y - totalH : y;
|
||||
const m = /outline(_full)?(?:_(\d)x)?/.exec(st);
|
||||
const ring = m ? (m[1] ? Number(m[2] || 1) : 1) : 0;
|
||||
lines.forEach((line, i) => {
|
||||
let s = line;
|
||||
if (widthCut > 0) {
|
||||
while (s.length > 1 && ctx.measureText(s).width > widthCut) s = s.slice(0, -1);
|
||||
}
|
||||
const ly = y0 + i * pitch;
|
||||
if (ring) {
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = ring * 2;
|
||||
ctx.strokeStyle = css(back, alpha);
|
||||
ctx.strokeText(s, x, ly);
|
||||
} else if (st.includes("shadow")) {
|
||||
ctx.fillStyle = css(back, alpha);
|
||||
ctx.fillText(s, x + 1, ly + 1);
|
||||
}
|
||||
ctx.fillStyle = css(text, alpha);
|
||||
ctx.fillText(s, x, ly);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTitle(ctx, doc, w, r, state) {
|
||||
const title = X.child(w, "Title");
|
||||
if (!title) return;
|
||||
const fmt = X.child(title, "Format");
|
||||
const colors = X.childrenOf(title, "Color");
|
||||
const disabled = state === "Disable";
|
||||
const col = colors.find((c) => low(X.attr(c, "Type")) === low(state)) || colors.find((c) => low(X.attr(c, "Type")) === "normal") || colors[0];
|
||||
const te = X.child(title, "Text");
|
||||
const str = te ? UIText.resolve(doc, te, X.text(doc, te)) : "";
|
||||
let style = X.attr(fmt, "Style") || "Normal";
|
||||
let dx = 0, dy = 0;
|
||||
if (disabled && X.attr(fmt, "DisableStyle")) {
|
||||
const ds = X.attr(fmt, "DisableStyle");
|
||||
if (low(ds) !== low(style) && low(ds) === "normal" && low(X.attr(fmt, "VertAlign") || "top") === "top") (dx = 2), (dy = 2);
|
||||
style = ds;
|
||||
}
|
||||
drawText(ctx, str, {
|
||||
x: r.x + X.num(fmt, "X") + dx,
|
||||
y: r.y + X.num(fmt, "Y") + dy,
|
||||
size: X.num(fmt, "Size", 12),
|
||||
style,
|
||||
halign: low(X.attr(fmt, "HorzAlign") || "left"),
|
||||
valign: low(X.attr(fmt, "VertAlign") || "top"),
|
||||
gap: X.num(fmt, "VertGap", 0),
|
||||
text: colorOf(X.attr(col, "Text")),
|
||||
back: colorOf(X.attr(col, "Back")),
|
||||
});
|
||||
}
|
||||
|
||||
/** Window-level <Text> list entries: Format fields as attributes or a <Format> child, one Color. */
|
||||
function drawTexts(ctx, doc, w, r) {
|
||||
for (const t of X.childrenOf(w, "Text")) {
|
||||
const fmt = X.child(t, "Format") || t;
|
||||
const col = X.child(t, "Color") || t;
|
||||
const body = X.child(t, "Text") || (t.children.length ? null : t);
|
||||
const raw = body ? X.text(doc, body) : "";
|
||||
if (!raw.trim()) continue;
|
||||
drawText(ctx, UIText.resolve(doc, body, raw), {
|
||||
x: r.x + X.num(fmt, "X"),
|
||||
y: r.y + X.num(fmt, "Y"),
|
||||
size: X.num(fmt, "Size", 12),
|
||||
style: X.attr(fmt, "Style") || "Normal",
|
||||
halign: low(X.attr(fmt, "HorzAlign") || "left"),
|
||||
valign: low(X.attr(fmt, "VertAlign") || "top"),
|
||||
gap: X.num(fmt, "VertGap", 0),
|
||||
text: colorOf(X.attr(col, "Text") || X.attr(col, "Color")),
|
||||
back: colorOf(X.attr(col, "Back")),
|
||||
widthCut: X.num(fmt, "WidthCut", 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ioRichLabel: <ExtraInfo><TextBlock Text Size Color Style/> … laid out one after another
|
||||
function drawTextBlocks(ctx, doc, w, r) {
|
||||
const extra = X.child(w, "ExtraInfo");
|
||||
const blocks = X.childrenOf(extra, "TextBlock");
|
||||
if (!blocks.length) return;
|
||||
let x = r.x, y = r.y;
|
||||
for (const b of blocks) {
|
||||
const raw = X.attr(b, "Text") || "";
|
||||
const str = /STR\(/i.test(raw) ? UIText.resolve(doc, b, raw) : LSF.decode(Uint8Array.from(raw, (c) => c.charCodeAt(0) & 255));
|
||||
const size = X.num(b, "Size", 12);
|
||||
const parts = str.split("#");
|
||||
parts.forEach((p, i) => {
|
||||
if (i) {
|
||||
x = r.x;
|
||||
y += size + 2;
|
||||
}
|
||||
const col = colorOf(X.attr(b, "Color") || "FFFFFFFF");
|
||||
drawText(ctx, p, { x, y, size, style: X.attr(b, "Style") || "Normal", text: col, back: colorOf("FF000000") });
|
||||
ctx.font = fontCss(size);
|
||||
x += ctx.measureText(p).width;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// EX classes (ioLabelWndEX / ioButtonWndEX / ioFrameWndEX): ExtraInfo children that fill the window
|
||||
const INT_H = ["left", "center", "right"];
|
||||
const INT_V = ["top", "center", "bottom"];
|
||||
function drawEx(ctx, doc, w, r, state) {
|
||||
const extra = X.child(w, "ExtraInfo");
|
||||
if (!extra) return;
|
||||
const want = { Normal: ["NormalFrame", "NormalImage"], Over: ["OverFrame", "OverImage"], Push: ["PushFrame", "PushImage"], Disable: ["DisableFrame", "NormalImage"] }[state] || ["NormalFrame", "NormalImage"];
|
||||
for (const c of extra.children) {
|
||||
const tag = c.tag;
|
||||
if (tag === "FrameInfo" || (tag.endsWith("Frame") && want.includes(tag)) || (tag === "OverAddFrame" && state === "Over")) {
|
||||
drawFrame(ctx, X.attr(c, "Template") || X.attr(c, "Templete"), r, X.attr(c, "Color"), tag === "OverAddFrame" ? "Add" : null);
|
||||
} else if (tag === "ImageInfo" || (tag.endsWith("Image") && want.includes(tag))) {
|
||||
const im = UIRes.imageSync(X.attr(c, "Imageset"), X.attr(c, "Image"), X.attr(c, "File"));
|
||||
if (im) blit(ctx, tinted(im, X.attr(c, "Color")), r.x, r.y, r.w || im.w, r.h || im.h);
|
||||
} else if (tag === "LabelInfo") {
|
||||
const h = INT_H[X.num(c, "HorzAlign", 0)] || "left";
|
||||
const v = INT_V[X.num(c, "VertAlign", 0)] || "top";
|
||||
const size = X.num(c, "Size", 12);
|
||||
const raw = X.attr(c, "Text") || "";
|
||||
drawText(ctx, LSF.decode(Uint8Array.from(raw, (ch) => ch.charCodeAt(0) & 255)), {
|
||||
x: r.x + (h === "center" ? r.w / 2 : h === "right" ? r.w : 0) + X.num(extra, "LabelOffsetX"),
|
||||
y: r.y + (v === "center" ? r.h / 2 : v === "bottom" ? r.h : 0) + X.num(extra, "LabelOffsetY"),
|
||||
size,
|
||||
halign: h,
|
||||
valign: v,
|
||||
text: colorOf(X.attr(c, state === "Over" ? "OverColor" : "NormalColor") || X.attr(c, "Color") || "FFFFFFFF"),
|
||||
back: colorOf(X.attr(c, "BKColor")),
|
||||
alpha: X.num(c, "Alpha", 255) / 255,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST = ["selected", "multiplyfrm", "selectedtitle"];
|
||||
// engine classes (ioGUIManager::CreateWnd): they draw every image Type in their list; client
|
||||
// classes claim their own Types and draw them in code, so an Area-less image with such a Type
|
||||
// isn't at the window origin in the game
|
||||
const ENGINE = new Set("ioWnd ioButton ioScreenButton ioConvexButton ioScaleButton ioCheckButton ioRadioButton ioStretchButton ioActiveScreenBtn ioEdit ioProgressBar ioScroll ioSlide ioToggleButton ioWndEX ioFrameWndEX ioImageWndEX ioLabelWndEX ioFrameButtonWndEX ioImageButtonWndEX ioCheckBoxEX ioFlashPlayer ioTabFrameButtonEX ioTabWndEX ioScrollBarEX ioRichLabel ioRadioWndEX ioRadioFrameButtonEX ioMovingWnd ioMovingBtn ioMovingNormalBtn ioCustomWnd".toLowerCase().split(" "));
|
||||
const GENERIC_TYPES = new Set(["", "normal", "back", "background", "bg"]);
|
||||
/** An image a client class draws itself (so the generic list skips it). */
|
||||
const classDrawn = (w, e) => {
|
||||
if (e.tag !== "Image" || X.child(e, "Area")) return false;
|
||||
const cls = low(X.attr(w, "ClassName"));
|
||||
if (ENGINE.has(cls)) return false;
|
||||
const t = low(X.attr(e, "Type"));
|
||||
return !GENERIC_TYPES.has(t) && !t.includes("#");
|
||||
};
|
||||
const BUTTON_STATES = ["normal", "over", "overadd", "push", "disable", "exdisablenor", "exdisablepush", "back", "normallist", "disablelist", "check"];
|
||||
|
||||
/** Draws one window (not its child windows) at r. opts: { state, text, images, preview } */
|
||||
function window(ctx, doc, w, r, { state = "Normal", text = true, images = true, preview = false } = {}) {
|
||||
const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : "";
|
||||
const inactive = /IWS_INACTIVE/.test(style);
|
||||
const st = inactive ? "Disable" : state;
|
||||
const items = X.childrenOf(w, "Imagery").flatMap((im) => im.children);
|
||||
const cls = X.attr(w, "ClassName") || "";
|
||||
const isButton = /button|btn/i.test(cls) || items.some((e) => ["over", "push"].includes(low(X.attr(e, "Type"))));
|
||||
const isCheck = /check/i.test(cls);
|
||||
const draw = (e) => {
|
||||
if (e.tag === "DefaultFrameImagery" || e.tag === "FrameImagery") return drawFrameEl(ctx, e, r);
|
||||
if (e.tag !== "Image") return;
|
||||
if (classDrawn(w, e)) {
|
||||
// placed by the class's code: hidden in the preview, faint at the origin while editing
|
||||
if (preview) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= 0.35;
|
||||
drawImageEl(ctx, e, r);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
drawImageEl(ctx, e, r);
|
||||
};
|
||||
ctx.save();
|
||||
if (inactive) ctx.filter = "grayscale(1)";
|
||||
if (images) {
|
||||
// Selected / MultiplyFrm / SelectedTitle frames first (ioWnd::OnRender)
|
||||
for (const e of items) if (FIRST.includes(low(X.attr(e, "Type")))) draw(e);
|
||||
if (isButton) {
|
||||
const of = (t) => items.filter((e) => low(X.attr(e, "Type")) === t);
|
||||
of("back").forEach(draw);
|
||||
const s = low(st);
|
||||
const stateItems = of(s === "disable" ? "disable" : s);
|
||||
(stateItems.length ? stateItems : of("normal")).forEach(draw);
|
||||
if (s === "over") of("overadd").forEach((e) => withBlend(ctx, "Add", () => draw(e)));
|
||||
if (isCheck) of("check").forEach(draw);
|
||||
// NormalList / DisableList images (the "x" of a close button…) come after the state art
|
||||
(s === "disable" && of("disablelist").length ? of("disablelist") : of("normallist")).forEach(draw);
|
||||
for (const e of items) {
|
||||
const t = low(X.attr(e, "Type"));
|
||||
if (!BUTTON_STATES.includes(t) && !FIRST.includes(t)) draw(e);
|
||||
}
|
||||
} else {
|
||||
for (const e of items) {
|
||||
const t = low(X.attr(e, "Type"));
|
||||
if (FIRST.includes(t) || ["over", "overadd", "push", "disable"].includes(t)) continue;
|
||||
draw(e);
|
||||
}
|
||||
}
|
||||
drawEx(ctx, doc, w, r, st);
|
||||
}
|
||||
if (text) {
|
||||
drawTitle(ctx, doc, w, r, st);
|
||||
drawTexts(ctx, doc, w, r);
|
||||
drawTextBlocks(ctx, doc, w, r);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** IWS_MODAL: a full-screen BaseImage001/strip_black behind the window (×3 for MODAL_EXTEND). */
|
||||
function modalBack(ctx, doc, w, screen) {
|
||||
const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : "";
|
||||
if (!/IWS_MODAL/.test(style)) return;
|
||||
const im = UIRes.imageSync("BaseImage001", "strip_black");
|
||||
const n = /IWS_MODAL_EXTEND/.test(style) ? 3 : 1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (im) blit(ctx, im, 0, 0, screen[0], screen[1]);
|
||||
else {
|
||||
ctx.fillStyle = "rgba(0,0,0,.5)";
|
||||
ctx.fillRect(0, 0, screen[0], screen[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every image name a document can draw (to load them before drawing). */
|
||||
function wanted(doc) {
|
||||
const out = [];
|
||||
for (const e of doc.elements) {
|
||||
const set = X.attr(e, "Imageset"), img = X.attr(e, "Image"), file = X.attr(e, "File");
|
||||
if ((set && img) || (file && /\.(dds|png|tga|bmp|jpg)$/i.test(file))) out.push([set, img, file]);
|
||||
const tpl = X.attr(e, "Templete") || X.attr(e, "Template");
|
||||
if (tpl) for (const p of UIRes.frame(tpl)?.parts || []) out.push([p.set, p.image]);
|
||||
}
|
||||
out.push(["BaseImage001", "strip_black"]);
|
||||
return out;
|
||||
}
|
||||
|
||||
return { window, modalBack, drawFrame, drawText, colorOf, wanted };
|
||||
})();
|
||||
window.UIDraw = UIDraw;
|
||||
|
||||
const UIProps = (() => {
|
||||
const X = XmlDoc;
|
||||
const ENUMS = {
|
||||
horzalign: ["Left", "Center", "Right", "Stretch", "Tile"],
|
||||
vertalign: ["Top", "Center", "Bottom", "Stretch", "Tile"],
|
||||
xalign: ["", "Left", "Center", "Right", "Rate"],
|
||||
yalign: ["", "Top", "Center", "Bottom", "Rate"],
|
||||
autoresize: ["True", "False"],
|
||||
rendertype: ["", "Normal", "Screen", "Gray", "Add", "AddColor", "ColorAlpha", "Lighten", "Darken", "Multiply", "GrayLight", "AddAlpha"],
|
||||
reverse: ["", "Horz", "Vert", "HorzVert"],
|
||||
style: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Outline_full_4x", "Bold", "Bold_shadow", "Bold_outline", "Bold_outline_full", "Bold_outline_full_2x", "Bold_outline_full_3x", "Bold_outline_full_4x"],
|
||||
disablestyle: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Bold", "Bold_shadow"],
|
||||
};
|
||||
/** An input for an attribute: colours get a picker, known enums a list, the rest text. */
|
||||
function input(el, name, value, onChange) {
|
||||
const n = name.toLowerCase();
|
||||
const enumOk = ENUMS[n] && !(n === "style" && el.tag !== "Format");
|
||||
if (enumOk) {
|
||||
const sel = document.createElement("select");
|
||||
for (const v of new Set([...ENUMS[n], value])) {
|
||||
const o = document.createElement("option");
|
||||
o.value = v;
|
||||
o.textContent = v || "(none)";
|
||||
o.selected = v === value;
|
||||
sel.append(o);
|
||||
}
|
||||
sel.onchange = () => onChange(sel.value);
|
||||
return sel;
|
||||
}
|
||||
if ((n === "templete" || n === "template") && UIRes.frameNames().length) return withList(value, onChange, "wnd-dl-frames", UIRes.frameNames());
|
||||
if (n === "imageset" && UIRes.imagesets().length) return withList(value, onChange, "wnd-dl-sets", UIRes.imagesets());
|
||||
const t = document.createElement("input");
|
||||
t.type = "text";
|
||||
t.value = value;
|
||||
t.spellcheck = false;
|
||||
t.addEventListener("change", () => onChange(t.value));
|
||||
t.addEventListener("keydown", (e) => e.key === "Enter" && t.blur());
|
||||
if (/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) && /color|^text$|^back$|bkcolor/i.test(name)) {
|
||||
const pick = document.createElement("input");
|
||||
pick.type = "color";
|
||||
pick.value = `#${value.slice(-6)}`;
|
||||
pick.onchange = () => onChange((value.length === 8 ? value.slice(0, 2) : "") + pick.value.slice(1).toUpperCase());
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "wnd-color";
|
||||
wrap.append(pick, t);
|
||||
return wrap;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
function withList(value, onChange, id, names) {
|
||||
if (!document.getElementById(id)) {
|
||||
const dl = document.createElement("datalist");
|
||||
dl.id = id;
|
||||
for (const n of names) {
|
||||
const o = document.createElement("option");
|
||||
o.value = n;
|
||||
dl.append(o);
|
||||
}
|
||||
document.body.append(dl);
|
||||
}
|
||||
const t = document.createElement("input");
|
||||
t.type = "text";
|
||||
t.value = value;
|
||||
t.spellcheck = false;
|
||||
t.setAttribute("list", id);
|
||||
t.addEventListener("change", () => onChange(t.value));
|
||||
return t;
|
||||
}
|
||||
|
||||
/** The quick form of a window: position, size, alignment. */
|
||||
function quick(doc, w, { edit }) {
|
||||
const s = X.child(w, "Size");
|
||||
const box = document.createElement("div");
|
||||
box.className = "wnd-quick";
|
||||
if (!s) return box;
|
||||
for (const k of ["X", "Y", "Width", "Height", "XAlign", "YAlign"]) {
|
||||
const lab = document.createElement("label");
|
||||
lab.className = "fld";
|
||||
const sp = document.createElement("span");
|
||||
sp.textContent = k;
|
||||
lab.append(sp, input(s, k, X.attr(s, k) ?? "", (v) => edit(s, (d, x) => (v === "" ? X.removeAttr(d, x, k) : X.setAttr(d, x, k, v)))));
|
||||
box.append(lab);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
return { input, quick };
|
||||
})();
|
||||
window.UIProps = UIProps;
|
||||
@@ -0,0 +1,739 @@
|
||||
"use strict";
|
||||
|
||||
// UI window editor (Wnd tab): the client's window layouts, rs.iop xml/*.xml (<Window> trees).
|
||||
// Shows the window as the game draws it (frames from xml/defaultuiframe.xml, images from
|
||||
// xml/uiimageset.xml, titles with their text), lets windows be moved / resized on the canvas and
|
||||
// every element / attribute be edited in the side panel or as XML source. Edits touch only the
|
||||
// characters they change (xmldoc.js), so a patch carries just what was edited.
|
||||
|
||||
(() => {
|
||||
const WND = {
|
||||
src: null,
|
||||
rows: [],
|
||||
doc: null,
|
||||
where: null,
|
||||
title: "",
|
||||
dirty: false,
|
||||
undo: [],
|
||||
redo: [],
|
||||
sel: null, // index of the selected <Window> in doc.elements
|
||||
zoom: 1,
|
||||
panX: 40,
|
||||
panY: 40,
|
||||
screen: [1024, 768],
|
||||
state: "Normal",
|
||||
showHidden: true,
|
||||
showBounds: true,
|
||||
tex: new Map(),
|
||||
};
|
||||
window.WND = WND;
|
||||
const toggled = (id) => $(id).classList.contains("on");
|
||||
const X = XmlDoc;
|
||||
const baseName = (p) => p.replace(/\\/g, "/").split("/").pop();
|
||||
const toLatin1 = (u8) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192));
|
||||
return s;
|
||||
};
|
||||
const fromLatin1 = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255);
|
||||
const cp949 = (s) => LSF.decode(fromLatin1(s));
|
||||
|
||||
// =====================================================================
|
||||
// source + list
|
||||
// =====================================================================
|
||||
async function setSource(src, { quiet = false } = {}) {
|
||||
try {
|
||||
const d = await call("mdl:describe", src);
|
||||
WND.src = { ...src, label: d.label };
|
||||
} catch (err) {
|
||||
return toast(err.message, true);
|
||||
}
|
||||
$("wnd-src-label").textContent = WND.src.kind === "dir" ? "Extracted folder" : WND.src.kind === "packs" ? "Pack folder" : `Packs next to ${WND.src.name}`;
|
||||
$("wnd-src").title = `Reading from ${WND.src.label}`;
|
||||
WND.tex.clear();
|
||||
UIRes.reset();
|
||||
await loadList();
|
||||
if (!quiet) toast(`Reading windows from ${WND.src.label}.`);
|
||||
}
|
||||
async function chooseSource() {
|
||||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||||
let choice = null;
|
||||
const options = [
|
||||
...profileSourceOption(),
|
||||
...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })),
|
||||
{ value: "packs", title: "A folder with .iop packs…", sub: "the client folder: rs.iop (xml/, texts), map.iop / map2.iop (textures)" },
|
||||
{ value: "dir", title: "A folder of extracted files…", sub: "xml/…, resource/texture… extracted from the packs" },
|
||||
];
|
||||
const list = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [el("input", { type: "radio", name: "wndsrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])])));
|
||||
choice = options[0].value;
|
||||
if (!(await modal("Where are the UI files?", ["Window layouts are in rs.iop (xml/…); their pictures in the textures of map.iop / map2.iop. Pick the folder with all of them.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false;
|
||||
let src = choice;
|
||||
if (choice === "packs" || choice === "dir") {
|
||||
const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files");
|
||||
if (!folder) return false;
|
||||
src = { kind: choice, folder };
|
||||
}
|
||||
await setSource(src);
|
||||
return true;
|
||||
}
|
||||
$("wnd-src").onclick = () => chooseSource();
|
||||
|
||||
const list = new VTable($("wnd-list"), [{ key: 1, label: "Window file", width: 220, grow: true, title: true }], { multi: false, id: "wnd" });
|
||||
list.keyOf = (r) => r[0];
|
||||
list.onOpen = (r) => openRow(r);
|
||||
|
||||
async function loadList() {
|
||||
if (!WND.src) return;
|
||||
try {
|
||||
const rows = await call("xml:list", WND.src);
|
||||
WND.rows = rows.map(([key, path, pack]) => [key, path.replace(/^.*?xml\//i, ""), pack, path]);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
WND.rows = [];
|
||||
}
|
||||
filterList();
|
||||
}
|
||||
function filterList() {
|
||||
const q = $("wnd-search").value.trim().toLowerCase();
|
||||
const rows = WND.rows.filter((r) => !q || r[1].toLowerCase().includes(q));
|
||||
list.setRows(rows);
|
||||
$("wnd-count").textContent = rows.length.toLocaleString();
|
||||
}
|
||||
$("wnd-search").addEventListener("input", filterList);
|
||||
|
||||
async function confirmDiscard() {
|
||||
if (!WND.dirty) return true;
|
||||
return confirmBox("Unsaved changes", "This window file has changes that aren't saved. Discard them?", "Discard", "danger");
|
||||
}
|
||||
async function openRow(r) {
|
||||
if (!r || !(await confirmDiscard())) return;
|
||||
try {
|
||||
const hit = await call("mdl:readKey", WND.src, r[0]);
|
||||
if (!hit) throw new Error("Not found in the packs any more.");
|
||||
await openText(toLatin1(hit.bytes), hit.where, r[1]);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
$("wnd-open-file").onclick = async () => {
|
||||
if (!(await confirmDiscard())) return;
|
||||
const file = await call("dialog:openXml");
|
||||
if (!file) return;
|
||||
try {
|
||||
const hit = await call("mdl:readFile", file);
|
||||
await openText(toLatin1(hit.bytes), hit.where, file);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
async function openText(text, where, title) {
|
||||
const doc = X.parse(text);
|
||||
if (!doc.root || doc.root.tag !== "Window") throw new Error(`This isn't a window layout (root <${doc.root?.tag || "?"}>).`);
|
||||
WND.doc = doc;
|
||||
WND.where = where;
|
||||
WND.title = title;
|
||||
WND.dirty = false;
|
||||
WND.undo = [];
|
||||
WND.redo = [];
|
||||
WND.sel = doc.elements.indexOf(doc.root);
|
||||
UIText.setFile(title);
|
||||
$("wnd-title").textContent = title;
|
||||
$("wnd-dirty").hidden = true;
|
||||
$("wnd-empty").hidden = true;
|
||||
$("wnd-side").hidden = false;
|
||||
if (doc.warnings?.length) toast(`Loaded with ${doc.warnings.length} XML quirk(s): ${doc.warnings[0]}`, true);
|
||||
await UIRes.load(WND.src);
|
||||
const tx = UIRes.texts();
|
||||
$("wnd-textinfo").textContent = tx ? `${tx.count.toLocaleString()} UI texts · ${tx.encoding}` : "no text.txt";
|
||||
$("wnd-textinfo").title = tx ? `STR(n) → ${UIText.key("n")} from ${tx.source}` : "";
|
||||
await prefetch();
|
||||
fit();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// edits (all through the source text, with undo)
|
||||
// =====================================================================
|
||||
function selEl() {
|
||||
return WND.doc?.elements[WND.sel] || null;
|
||||
}
|
||||
/** Path of an element (tag + index among same-tag siblings) so it can be found again after a re-parse. */
|
||||
function pathOf(el) {
|
||||
const p = [];
|
||||
for (let e = el; e && e.parent; e = e.parent) p.unshift([e.tag, e.parent.children.filter((c) => c.tag === e.tag).indexOf(e)]);
|
||||
return p;
|
||||
}
|
||||
function byPath(doc, p) {
|
||||
let e = doc.root;
|
||||
for (const [tag, i] of p) {
|
||||
e = e?.children.filter((c) => c.tag === tag)[i];
|
||||
if (!e) return null;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
function apply(newSrc, { keep = true } = {}) {
|
||||
if (newSrc === WND.doc.src) return;
|
||||
const selPath = keep && selEl() ? pathOf(selEl()) : null;
|
||||
WND.undo.push(WND.doc.src);
|
||||
if (WND.undo.length > 300) WND.undo.shift();
|
||||
WND.redo = [];
|
||||
reparse(newSrc, selPath);
|
||||
}
|
||||
function reparse(src, selPath) {
|
||||
WND.doc = X.parse(src);
|
||||
const s = selPath ? byPath(WND.doc, selPath) : null;
|
||||
WND.sel = WND.doc.elements.indexOf(s || WND.doc.root);
|
||||
WND.dirty = true;
|
||||
$("wnd-dirty").hidden = false;
|
||||
renderAll();
|
||||
}
|
||||
function undoRedo(from, to) {
|
||||
const s = from.pop();
|
||||
if (s === undefined) return;
|
||||
const selPath = selEl() ? pathOf(selEl()) : null;
|
||||
to.push(WND.doc.src);
|
||||
reparse(s, selPath);
|
||||
}
|
||||
$("wnd-undo").onclick = () => undoRedo(WND.undo, WND.redo);
|
||||
$("wnd-redo").onclick = () => undoRedo(WND.redo, WND.undo);
|
||||
/** Edits one element: fn(doc, el) → new source. */
|
||||
const editEl = (el, fn) => apply(fn(WND.doc, el));
|
||||
|
||||
async function save(as = false) {
|
||||
if (!WND.doc) return;
|
||||
let where = WND.where;
|
||||
if (as || !where) {
|
||||
const file = await call("dialog:saveXml", baseName(WND.title || "window.xml"));
|
||||
if (!file) return;
|
||||
where = { kind: "file", file };
|
||||
} else if (where.kind === "pack" && !(await confirmBox("Save into the pack", `Write ${where.entry} into ${baseName(where.file)}? The new copy replaces the old one.`, "Save"))) return;
|
||||
try {
|
||||
await call("mdl:writeBack", where, fromLatin1(WND.doc.src));
|
||||
WND.where = where;
|
||||
WND.dirty = false;
|
||||
$("wnd-dirty").hidden = true;
|
||||
toast(`Saved ${where.kind === "pack" ? `${where.entry} into ${baseName(where.file)}` : where.file}.`);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
$("wnd-save").onclick = (e) =>
|
||||
showMenu(
|
||||
[
|
||||
{ label: WND.where?.kind === "pack" ? `Save into ${baseName(WND.where.file)}` : "Save", icon: WND.where?.kind === "pack" ? "box" : "save", hint: "Ctrl+S", disabled: !WND.doc, run: () => save() },
|
||||
{ label: "Save as file…", icon: "download", disabled: !WND.doc, run: () => save(true) },
|
||||
"-",
|
||||
{ label: "Screenshot (PNG)…", icon: "camera", disabled: !WND.doc, run: screenshot },
|
||||
],
|
||||
{ anchor: e.currentTarget },
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// layout (window rectangles in screen space)
|
||||
// =====================================================================
|
||||
const windows = () => (WND.doc ? WND.doc.elements.filter((e) => e.tag === "Window") : []);
|
||||
const sizeOf = (w) => X.child(w, "Size");
|
||||
const styleText = (w) => (X.child(w, "Style") ? X.text(WND.doc, X.child(w, "Style")) : "");
|
||||
const isHidden = (w) => /IWS_START_HIDE/.test(styleText(w));
|
||||
|
||||
/** Screen rectangle of a window: its Size relative to the parent window (with X/Y align). */
|
||||
function rectOf(w, cache = new Map()) {
|
||||
if (cache.has(w)) return cache.get(w);
|
||||
const s = sizeOf(w);
|
||||
const pw = w.parent && w.parent.tag === "Window" ? w.parent : null;
|
||||
const pr = pw ? rectOf(pw, cache) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] };
|
||||
// ioWnd::SetSize: Width/Height "Parent" fill the parent; X/YAlign Center / Right / Bottom
|
||||
// position against the parent, Rate scales by screen / 800×600
|
||||
let x = X.num(s, "X"), y = X.num(s, "Y");
|
||||
const wd = /^parent$/i.test(X.attr(s, "Width") || "") ? pr.w : X.num(s, "Width");
|
||||
const ht = /^parent$/i.test(X.attr(s, "Height") || "") ? pr.h : X.num(s, "Height");
|
||||
const xa = (X.attr(s, "XAlign") || "").toLowerCase();
|
||||
const ya = (X.attr(s, "YAlign") || "").toLowerCase();
|
||||
if (xa === "center") x = x + (pr.w - wd) / 2;
|
||||
else if (xa === "right") x = x + pr.w - wd;
|
||||
else if (xa === "rate") x = x * (WND.screen[0] / 800);
|
||||
if (ya === "center") y = y + (pr.h - ht) / 2;
|
||||
else if (ya === "bottom") y = y + pr.h - ht;
|
||||
else if (ya === "rate") y = y * (WND.screen[1] / 600);
|
||||
const r = { x: pr.x + x, y: pr.y + y, w: wd, h: ht, lx: x, ly: y };
|
||||
cache.set(w, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// drawing (UIRes / UIDraw do the game-specific parts)
|
||||
// =====================================================================
|
||||
const canvas = $("wnd-canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
function resize() {
|
||||
const r = canvas.parentElement.getBoundingClientRect();
|
||||
const dpr = devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.round(r.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(r.height * dpr));
|
||||
canvas.style.width = `${r.width}px`;
|
||||
canvas.style.height = `${r.height}px`;
|
||||
draw();
|
||||
}
|
||||
new ResizeObserver(resize).observe(canvas.parentElement);
|
||||
|
||||
function fit() {
|
||||
const r = canvas.parentElement.getBoundingClientRect();
|
||||
const root = WND.doc?.root;
|
||||
const rr = root ? rectOf(root) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] };
|
||||
const w = Math.max(rr.w, 50), h = Math.max(rr.h, 50);
|
||||
WND.zoom = Math.max(0.1, Math.min(4, Math.min((r.width - 60) / w, (r.height - 60) / h)));
|
||||
WND.panX = (r.width - w * WND.zoom) / 2 - rr.x * WND.zoom;
|
||||
WND.panY = (r.height - h * WND.zoom) / 2 - rr.y * WND.zoom;
|
||||
$("wnd-zoom").textContent = `${Math.round(WND.zoom * 100)}%`;
|
||||
draw();
|
||||
}
|
||||
|
||||
async function prefetch() {
|
||||
// textures used by the file, so the first draw has them
|
||||
await Promise.all(UIDraw.wanted(WND.doc).map(([s, i, f]) => UIRes.image(s, i, f)));
|
||||
}
|
||||
|
||||
let drawQueued = false;
|
||||
function draw() {
|
||||
if (drawQueued) return;
|
||||
drawQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
drawQueued = false;
|
||||
drawNow();
|
||||
});
|
||||
}
|
||||
function drawNow() {
|
||||
const dpr = devicePixelRatio || 1;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!WND.doc) return;
|
||||
ctx.setTransform(dpr * WND.zoom, 0, 0, dpr * WND.zoom, dpr * WND.panX, dpr * WND.panY);
|
||||
// the game screen: an opaque, scene-like backdrop (Multiply / Add images need something under them)
|
||||
backdrop(ctx);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.12)";
|
||||
ctx.lineWidth = 1 / WND.zoom;
|
||||
ctx.strokeRect(0, 0, WND.screen[0], WND.screen[1]);
|
||||
const cache = new Map();
|
||||
const preview = toggled("wnd-t-preview");
|
||||
const showHidden = WND.showHidden && !preview;
|
||||
if (preview) UIDraw.modalBack(ctx, WND.doc, WND.doc.root, WND.screen);
|
||||
const drawWin = (w) => {
|
||||
if (!showHidden && isHidden(w) && w !== WND.doc.root) return;
|
||||
const r = rectOf(w, cache);
|
||||
UIDraw.window(ctx, WND.doc, w, r, { state: WND.state, text: toggled("wnd-t-text"), images: toggled("wnd-t-img"), preview });
|
||||
for (const c of w.children) if (c.tag === "Window") drawWin(c);
|
||||
};
|
||||
drawWin(WND.doc.root);
|
||||
// bounds + selection
|
||||
const px = 1 / WND.zoom;
|
||||
if (preview) return;
|
||||
if (WND.showBounds) {
|
||||
ctx.lineWidth = px;
|
||||
for (const w of windows()) {
|
||||
if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue;
|
||||
const r = rectOf(w, cache);
|
||||
ctx.strokeStyle = isHidden(w) ? "rgba(150,150,255,.35)" : "rgba(233,180,60,.35)";
|
||||
ctx.setLineDash(isHidden(w) ? [3 * px, 3 * px] : []);
|
||||
ctx.strokeRect(r.x + px / 2, r.y + px / 2, Math.max(0, r.w - px), Math.max(0, r.h - px));
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
const s = selWindow();
|
||||
if (s) {
|
||||
const r = rectOf(s, cache);
|
||||
ctx.lineWidth = 2 * px;
|
||||
ctx.strokeStyle = "#f4c95a";
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||
ctx.fillStyle = "#f4c95a";
|
||||
for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px);
|
||||
ctx.font = `${11 * px}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
const label = `${X.attr(s, "ClassName") || "Window"} #${X.attr(s, "Id") ?? ""} ${Math.round(r.lx)},${Math.round(r.ly)} ${r.w}×${r.h}`;
|
||||
const tw = ctx.measureText(label).width + 8 * px;
|
||||
ctx.fillStyle = "rgba(20,19,24,.9)";
|
||||
ctx.fillRect(r.x, r.y - 16 * px, tw, 15 * px);
|
||||
ctx.fillStyle = "#f4c95a";
|
||||
ctx.fillText(label, r.x + 4 * px, r.y - 5 * px);
|
||||
}
|
||||
}
|
||||
function backdrop(g) {
|
||||
const grad = g.createLinearGradient(0, 0, 0, WND.screen[1]);
|
||||
grad.addColorStop(0, "#3b4a5e");
|
||||
grad.addColorStop(1, "#1b222c");
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, WND.screen[0], WND.screen[1]);
|
||||
}
|
||||
const handles = (r) => [
|
||||
[r.x, r.y, "nw"], [r.x + r.w / 2, r.y, "n"], [r.x + r.w, r.y, "ne"], [r.x + r.w, r.y + r.h / 2, "e"],
|
||||
[r.x + r.w, r.y + r.h, "se"], [r.x + r.w / 2, r.y + r.h, "s"], [r.x, r.y + r.h, "sw"], [r.x, r.y + r.h / 2, "w"],
|
||||
];
|
||||
const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
|
||||
function selWindow() {
|
||||
let e = selEl();
|
||||
while (e && e.tag !== "Window") e = e.parent;
|
||||
return e;
|
||||
}
|
||||
|
||||
// ---------- mouse: select, move, resize, pan, zoom ----------
|
||||
const toScreen = (e) => {
|
||||
const b = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - b.left - WND.panX) / WND.zoom, y: (e.clientY - b.top - WND.panY) / WND.zoom };
|
||||
};
|
||||
function hitWindow(p) {
|
||||
const cache = new Map();
|
||||
let best = null;
|
||||
let area = Infinity;
|
||||
for (const w of windows()) {
|
||||
if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue;
|
||||
const r = rectOf(w, cache);
|
||||
if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h <= area) {
|
||||
best = w;
|
||||
area = r.w * r.h;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function hitHandle(p) {
|
||||
const s = selWindow();
|
||||
if (!s) return null;
|
||||
const r = rectOf(s);
|
||||
const tol = 6 / WND.zoom;
|
||||
return handles(r).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null;
|
||||
}
|
||||
let drag = null;
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
if (!WND.doc) return;
|
||||
canvas.focus();
|
||||
const p = toScreen(e);
|
||||
if (e.button !== 0 || e.altKey) {
|
||||
drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY };
|
||||
return e.preventDefault();
|
||||
}
|
||||
const h = hitHandle(p);
|
||||
const s = selWindow();
|
||||
if (h && s) {
|
||||
drag = { mode: "resize", h, start: p, w: s, s: sizeRect(s), before: WND.doc.src, path: pathOf(s) };
|
||||
} else {
|
||||
const w = hitWindow(p);
|
||||
if (w) {
|
||||
select(w);
|
||||
drag = { mode: "move", start: p, w, s: sizeRect(w), before: WND.doc.src, path: pathOf(w), moved: false };
|
||||
} else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY };
|
||||
}
|
||||
e.preventDefault();
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
const sizeRect = (w) => {
|
||||
const s = sizeOf(w);
|
||||
return { x: X.num(s, "X"), y: X.num(s, "Y"), w: X.num(s, "Width"), h: X.num(s, "Height"), xa: (X.attr(s, "XAlign") || "").toLowerCase(), ya: (X.attr(s, "YAlign") || "").toLowerCase() };
|
||||
};
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!$("view-wnd").classList.contains("on") || !WND.doc) return;
|
||||
if (!drag) {
|
||||
if (e.target !== canvas) return;
|
||||
const p = toScreen(e);
|
||||
const h = hitHandle(p);
|
||||
canvas.style.cursor = h ? CURSOR[h] : hitWindow(p) ? "move" : "grab";
|
||||
$("wnd-hover").textContent = `${Math.floor(p.x)}, ${Math.floor(p.y)}`;
|
||||
return;
|
||||
}
|
||||
if (drag.mode === "pan") {
|
||||
WND.panX = drag.px + e.clientX - drag.sx;
|
||||
WND.panY = drag.py + e.clientY - drag.sy;
|
||||
return draw();
|
||||
}
|
||||
const p = toScreen(e);
|
||||
const dx = Math.round(p.x - drag.start.x);
|
||||
const dy = Math.round(p.y - drag.start.y);
|
||||
const s0 = drag.s;
|
||||
const sx = s0.xa === "rate" ? 800 / WND.screen[0] : 1;
|
||||
const sy = s0.ya === "rate" ? 600 / WND.screen[1] : 1;
|
||||
let { x, y, w, h } = s0;
|
||||
if (drag.mode === "move") {
|
||||
x += dx * sx;
|
||||
y += dy * sy;
|
||||
} else {
|
||||
if (drag.h.includes("e")) w = Math.max(0, s0.w + dx);
|
||||
if (drag.h.includes("s")) h = Math.max(0, s0.h + dy);
|
||||
if (drag.h.includes("w")) {
|
||||
w = Math.max(0, s0.w - dx);
|
||||
x = s0.x + dx * sx * (s0.xa === "center" ? 0.5 : 1);
|
||||
}
|
||||
if (drag.h.includes("n")) {
|
||||
h = Math.max(0, s0.h - dy);
|
||||
y = s0.y + dy * sy * (s0.ya === "center" ? 0.5 : 1);
|
||||
}
|
||||
if (s0.xa === "center" && drag.h.includes("e")) x = s0.x + dx / 2;
|
||||
if (s0.ya === "center" && drag.h.includes("s")) y = s0.y + dy / 2;
|
||||
}
|
||||
// live: write straight into the source (undo is added on mouse-up)
|
||||
const doc = X.parse(drag.before);
|
||||
const win = byPath(doc, drag.path);
|
||||
let src = drag.before;
|
||||
const set = (k, v) => {
|
||||
const d = X.parse(src);
|
||||
src = X.setAttr(d, X.child(byPath(d, drag.path), "Size"), k, String(Math.round(v)));
|
||||
};
|
||||
if (win && X.child(win, "Size")) {
|
||||
if (Math.round(x) !== s0.x || drag.mode === "resize") set("X", x);
|
||||
if (Math.round(y) !== s0.y || drag.mode === "resize") set("Y", y);
|
||||
if (drag.mode === "resize") {
|
||||
set("Width", w);
|
||||
set("Height", h);
|
||||
}
|
||||
drag.moved = true;
|
||||
WND.doc = X.parse(src);
|
||||
WND.sel = WND.doc.elements.indexOf(byPath(WND.doc, drag.path));
|
||||
draw();
|
||||
$("wnd-hover").textContent = `X ${Math.round(x)} Y ${Math.round(y)} ${Math.round(w)} × ${Math.round(h)}`;
|
||||
}
|
||||
});
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (!drag) return;
|
||||
const d = drag;
|
||||
drag = null;
|
||||
if ((d.mode === "move" || d.mode === "resize") && d.moved && WND.doc.src !== d.before) {
|
||||
WND.undo.push(d.before);
|
||||
WND.redo = [];
|
||||
WND.dirty = true;
|
||||
$("wnd-dirty").hidden = false;
|
||||
renderSide();
|
||||
}
|
||||
canvas.style.cursor = "";
|
||||
});
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const b = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - b.left, cy = e.clientY - b.top;
|
||||
const nz = Math.max(0.1, Math.min(8, WND.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
|
||||
WND.panX = cx - ((cx - WND.panX) * nz) / WND.zoom;
|
||||
WND.panY = cy - ((cy - WND.panY) * nz) / WND.zoom;
|
||||
WND.zoom = nz;
|
||||
$("wnd-zoom").textContent = `${Math.round(nz * 100)}%`;
|
||||
draw();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
$("wnd-fit").onclick = fit;
|
||||
$("wnd-zoom").onclick = () => {
|
||||
WND.zoom = 1;
|
||||
$("wnd-zoom").textContent = "100%";
|
||||
draw();
|
||||
};
|
||||
$("wnd-screen").onchange = () => {
|
||||
WND.screen = $("wnd-screen").value.split("x").map(Number);
|
||||
fit();
|
||||
};
|
||||
$("wnd-state").onchange = () => {
|
||||
WND.state = $("wnd-state").value;
|
||||
draw();
|
||||
};
|
||||
for (const id of ["wnd-t-text", "wnd-t-img"]) $(id).onclick = () => ($(id).classList.toggle("on"), draw());
|
||||
$("wnd-t-preview").onclick = () => {
|
||||
$("wnd-t-preview").classList.toggle("on");
|
||||
draw();
|
||||
};
|
||||
$("wnd-t-bounds").onclick = () => {
|
||||
WND.showBounds = !WND.showBounds;
|
||||
$("wnd-t-bounds").classList.toggle("on", WND.showBounds);
|
||||
draw();
|
||||
};
|
||||
$("wnd-t-hidden").onclick = () => {
|
||||
WND.showHidden = !WND.showHidden;
|
||||
$("wnd-t-hidden").classList.toggle("on", WND.showHidden);
|
||||
draw();
|
||||
renderTree();
|
||||
};
|
||||
|
||||
async function screenshot() {
|
||||
const file = await call("dialog:savePng", `${baseName(WND.title).replace(/\.xml$/i, "")}.png`);
|
||||
if (!file) return;
|
||||
const root = rectOf(WND.doc.root);
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.max(1, root.w);
|
||||
c.height = Math.max(1, root.h);
|
||||
const g = c.getContext("2d");
|
||||
g.translate(-root.x, -root.y);
|
||||
backdrop(g);
|
||||
const cache = new Map();
|
||||
const drawWin = (w) => {
|
||||
if (isHidden(w) && w !== WND.doc.root) return; // the screenshot shows what the game shows
|
||||
UIDraw.window(g, WND.doc, w, rectOf(w, cache), { state: WND.state, text: true, images: true, preview: true });
|
||||
for (const ch of w.children) if (ch.tag === "Window") drawWin(ch);
|
||||
};
|
||||
drawWin(WND.doc.root);
|
||||
await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]);
|
||||
toast(`Saved ${file}.`);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// side panel: window tree, properties, source
|
||||
// =====================================================================
|
||||
function select(el) {
|
||||
WND.sel = WND.doc.elements.indexOf(el);
|
||||
renderSide();
|
||||
draw();
|
||||
}
|
||||
function renderAll() {
|
||||
renderSide();
|
||||
draw();
|
||||
$("wnd-undo").disabled = !WND.undo.length;
|
||||
$("wnd-redo").disabled = !WND.redo.length;
|
||||
}
|
||||
function renderSide() {
|
||||
renderTree();
|
||||
renderProps();
|
||||
$("wnd-undo").disabled = !WND.undo.length;
|
||||
$("wnd-redo").disabled = !WND.redo.length;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const out = [];
|
||||
const sw = selWindow();
|
||||
const walk = (w, depth) => {
|
||||
const hidden = isHidden(w);
|
||||
if (!WND.showHidden && hidden && w !== WND.doc.root) return;
|
||||
const title = UIText.titleOf(WND.doc, w);
|
||||
out.push(
|
||||
el("div", { class: `wnd-node${w === sw ? " on" : ""}${hidden ? " hid" : ""}`, style: `padding-left:${8 + depth * 14}px`, onclick: () => select(w) }, [
|
||||
el("b", { text: X.attr(w, "ClassName") || "Window" }),
|
||||
el("em", { text: `#${X.attr(w, "Id") ?? ""}${title ? ` ${title}` : ""}` }),
|
||||
]),
|
||||
);
|
||||
for (const c of w.children) if (c.tag === "Window") walk(c, depth + 1);
|
||||
};
|
||||
if (WND.doc) walk(WND.doc.root, 0);
|
||||
$("wnd-tree").replaceChildren(...out);
|
||||
$("wnd-tree").querySelector(".on")?.scrollIntoView({ block: "nearest" });
|
||||
$("wnd-count-win").textContent = String(windows().length);
|
||||
}
|
||||
|
||||
/** One element as an editable card: its attributes, text, child elements (not child windows). */
|
||||
function elementCard(e, depth = 0) {
|
||||
const doc = WND.doc;
|
||||
const rows = [];
|
||||
for (const a of e.attrs) {
|
||||
const input = UIProps.input(e, a.name, a.value, (v) => editEl(e, (d, x) => X.setAttr(d, byPath(d, pathOf(x)) || x, a.name, v)));
|
||||
rows.push(
|
||||
el("div", { class: "map-kv" }, [
|
||||
el("span", { class: "k", text: a.name }),
|
||||
input,
|
||||
el("button", { class: "iconbtn only", title: "Remove this attribute", onclick: () => editEl(e, (d, x) => X.removeAttr(d, x, a.name)) }, [icon("minus")]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (!e.children.length && (e.textStart !== undefined || ["Style", "ExStyle", "Text"].includes(e.tag))) {
|
||||
const t = X.text(doc, e);
|
||||
const input = el("input", { type: "text", value: t.trim(), spellcheck: "false" });
|
||||
input.addEventListener("change", () => editEl(e, (d, x) => X.setText(d, x, input.value)));
|
||||
const resolved = e.tag === "Text" ? UIText.resolve(doc, e, t) : "";
|
||||
rows.push(el("div", { class: "map-kv" }, [el("span", { class: "k", text: "(text)" }), input, el("span")]));
|
||||
if (resolved && resolved !== t.trim()) rows.push(el("div", { class: "wnd-resolved", text: `→ ${resolved}` }));
|
||||
}
|
||||
// add attribute
|
||||
const nk = el("input", { type: "text", placeholder: "attribute", spellcheck: "false" });
|
||||
const nv = el("input", { type: "text", placeholder: "value", spellcheck: "false" });
|
||||
const add = () => nk.value.trim() && editEl(e, (d, x) => X.setAttr(d, x, nk.value.trim(), nv.value));
|
||||
nv.addEventListener("keydown", (ev) => ev.key === "Enter" && add());
|
||||
const kids = e.children.filter((c) => c.tag !== "Window");
|
||||
const head = el("div", { class: "wnd-el-head" }, [
|
||||
el("b", { text: `<${e.tag}>` }),
|
||||
el("span", { class: "spacer" }),
|
||||
el("button", { class: "iconbtn only", title: "Copy this element", onclick: () => editEl(e, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy")]),
|
||||
el("button", { class: "iconbtn only danger", title: "Remove this element", onclick: () => editEl(e, (d, x) => X.removeElement(d, x)) }, [icon("trash")]),
|
||||
]);
|
||||
return el("div", { class: `wnd-el depth${Math.min(depth, 3)}` }, [head, ...rows, el("div", { class: "map-kv add" }, [nk, nv, el("button", { class: "iconbtn only", title: "Add attribute", onclick: add }, [icon("plus")])]), ...kids.map((k) => elementCard(k, depth + 1))]);
|
||||
}
|
||||
|
||||
function renderProps() {
|
||||
const w = selWindow();
|
||||
$("wnd-props-card").hidden = !w;
|
||||
if (!w) return;
|
||||
$("wnd-props-name").textContent = `${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`;
|
||||
const doc = WND.doc;
|
||||
const quick = UIProps.quick(doc, w, { edit: editEl, pathOf, byPath });
|
||||
const tools = el("div", { class: "map-sec-tools" }, [
|
||||
el("button", { class: "iconbtn", title: "Add a child window", onclick: () => addChild(w) }, [icon("plus"), el("span", { text: "Child" })]),
|
||||
el("button", { class: "iconbtn", title: "Duplicate this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy"), el("span", { text: "Copy" })]),
|
||||
el("button", { class: "iconbtn", title: "Add an element (Title, Imagery, ExtraInfo…)", onclick: (ev) => addElementMenu(ev, w) }, [icon("fileplus"), el("span", { text: "Element" })]),
|
||||
el("span", { class: "spacer" }),
|
||||
el("button", { class: "iconbtn only", title: "Edit this window as XML", onclick: () => editSource(w) }, [icon("note")]),
|
||||
el("button", { class: "iconbtn only danger", title: "Delete this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.removeElement(d, x)) }, [icon("trash")]),
|
||||
]);
|
||||
$("wnd-props").replaceChildren(tools, quick, el("h5", { text: "Elements" }), ...w.children.filter((c) => c.tag !== "Window").map((c) => elementCard(c)));
|
||||
}
|
||||
|
||||
function addChild(w) {
|
||||
const ids = w.children.filter((c) => c.tag === "Window").map((c) => Number(X.attr(c, "Id")) || 0);
|
||||
const id = (ids.length ? Math.max(...ids) : 0) + 1;
|
||||
editEl(w, (d, x) => X.insertChild(d, x, `<Window ClassName="ioWnd" Id="${id}">\n\t<Size X="10" Y="10" Width="100" Height="30" />\n</Window>`));
|
||||
}
|
||||
function addElementMenu(ev, w) {
|
||||
const snippets = {
|
||||
Title: `<Title>\n\t<Format X="0" Y="0" Size="12" Style="Normal" HorzAlign="Left"/>\n\t<Color Type="Normal" Text="FFFFFFFF" Back="FF000000"/>\n\t<Text>Text</Text>\n</Title>`,
|
||||
"Imagery (image)": `<Imagery>\n\t<Image Type="Normal" Imageset="BaseImage001" Image="none"/>\n</Imagery>`,
|
||||
"Imagery (frame)": `<Imagery>\n\t<DefaultFrameImagery Type="Normal" Templete="slot_empty_white">\n\t\t<Area X="0" Y="0" Width="100" Height="30"/>\n\t</DefaultFrameImagery>\n</Imagery>`,
|
||||
Style: `<Style>IWS_PROCESS</Style>`,
|
||||
ExStyle: `<ExStyle>IWS_EX_PUSH_DOUBLE</ExStyle>`,
|
||||
ExtraInfo: `<ExtraInfo />`,
|
||||
};
|
||||
showMenu(Object.entries(snippets).map(([label, s]) => ({ label, icon: "plus", run: () => editEl(w, (d, x) => X.insertChild(d, x, s)) })), { x: ev.clientX, y: ev.clientY });
|
||||
}
|
||||
async function editSource(w) {
|
||||
const ta = el("textarea", { class: "mdl-mat", spellcheck: "false" });
|
||||
ta.value = cp949(X.markupOf(WND.doc, w));
|
||||
const ok = await modal(`XML — ${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`, [el("p", { class: "muted", text: "The window and its children as XML. Korean text is shown decoded and written back as CP949." }), ta], [{ label: "Cancel", value: false }, { label: "Apply", kind: "primary", value: true }]);
|
||||
if (!ok) return;
|
||||
let bytes;
|
||||
try {
|
||||
bytes = UIText.encodeCp949(ta.value);
|
||||
} catch (err) {
|
||||
return toast(err.message, true);
|
||||
}
|
||||
const markup = toLatin1(bytes);
|
||||
try {
|
||||
X.parse(markup);
|
||||
} catch (err) {
|
||||
return toast(`Not valid XML: ${err.message}`, true);
|
||||
}
|
||||
editEl(w, (d, x) => d.src.slice(0, x.start) + markup + d.src.slice(x.end));
|
||||
}
|
||||
|
||||
// keys
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!$("view-wnd").classList.contains("on") || !WND.doc) return;
|
||||
if (e.target.matches?.("input, textarea, select")) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (e.ctrlKey && k === "z") return e.preventDefault(), undoRedo(WND.undo, WND.redo);
|
||||
if (e.ctrlKey && k === "y") return e.preventDefault(), undoRedo(WND.redo, WND.undo);
|
||||
if (e.ctrlKey && k === "s") return e.preventDefault(), save();
|
||||
const w = selWindow();
|
||||
const mv = { arrowleft: [-1, 0], arrowright: [1, 0], arrowup: [0, -1], arrowdown: [0, 1] }[k];
|
||||
if (w && mv && sizeOf(w)) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const s = sizeRect(w);
|
||||
const sx = 1, sy = 1;
|
||||
let src = WND.doc.src;
|
||||
let d = WND.doc;
|
||||
if (mv[0]) src = X.setAttr(d, sizeOf(w), "X", String(s.x + mv[0] * step * sx));
|
||||
d = X.parse(src);
|
||||
if (mv[1]) src = X.setAttr(d, X.child(byPath(d, pathOf(w)), "Size"), "Y", String(s.y + mv[1] * step * sy));
|
||||
apply(src);
|
||||
}
|
||||
if (e.key === "Delete" && w && w !== WND.doc.root) editEl(w, (d, x) => X.removeElement(d, x));
|
||||
});
|
||||
|
||||
document.querySelector('[data-view="wnd"]').addEventListener("click", async () => {
|
||||
resize();
|
||||
if (WND.src) return;
|
||||
if (profileSource()) return setSource(profileSource(), { quiet: true });
|
||||
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
|
||||
const s = sessions.find((x) => /^rs\.iop$/i.test(x.name)) || sessions[0];
|
||||
if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true });
|
||||
});
|
||||
|
||||
Object.assign(WND, { setSource, openRow, openText, draw: drawNow, select, rectOf, windows, save, apply });
|
||||
})();
|
||||
@@ -0,0 +1,283 @@
|
||||
"use strict";
|
||||
|
||||
// Virtual table: only the visible rows exist in the DOM, so 50 000+ pack entries scroll smoothly.
|
||||
// columns: [{ key, label, width (px), grow, cls, format(row) → text|Node, sort(row) → value }]
|
||||
// Column widths can be dragged at the header's right edge; the `grow` column takes the free width.
|
||||
// The table scrolls sideways when the columns are wider than the view.
|
||||
|
||||
class VTable {
|
||||
constructor(host, columns, { rowHeight = 28, multi = true, id = null } = {}) {
|
||||
this.host = host;
|
||||
this.columns = columns;
|
||||
this.rh = rowHeight;
|
||||
this.multi = multi;
|
||||
this.id = id; // remembers column widths per table
|
||||
this.rows = [];
|
||||
this.view = [];
|
||||
this.sel = new Set();
|
||||
this.anchor = -1;
|
||||
this.sortKey = null;
|
||||
this.sortDesc = false;
|
||||
this.sortFirst = null; // (row) → rank, applied before the column sort (folders first)
|
||||
this.onSelect = () => {};
|
||||
this.onOpen = () => {};
|
||||
this.keyOf = (r) => r.key ?? r[0];
|
||||
this.loadWidths();
|
||||
|
||||
host.innerHTML = "";
|
||||
host.classList.add("vtable");
|
||||
this.head = document.createElement("div");
|
||||
this.head.className = "vt-head";
|
||||
this.body = document.createElement("div");
|
||||
this.body.className = "vt-rows";
|
||||
host.append(this.head, this.body);
|
||||
for (const c of columns) {
|
||||
const h = document.createElement("div");
|
||||
h.className = "vt-h";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = c.label;
|
||||
h.append(label);
|
||||
if (c.cls?.includes("num")) h.style.justifyContent = "flex-end";
|
||||
h.onclick = (e) => !e.target.classList.contains("vt-resize") && this.sortBy(c.key);
|
||||
const grip = document.createElement("i");
|
||||
grip.className = "vt-resize";
|
||||
grip.onmousedown = (e) => this.startResize(e, c);
|
||||
grip.ondblclick = () => this.autoFit(c);
|
||||
h.append(grip);
|
||||
c.headEl = h;
|
||||
this.head.append(h);
|
||||
}
|
||||
host.addEventListener("scroll", () => this.draw());
|
||||
// Double-click is caught here: rows are re-created while scrolling, so a per-row handler
|
||||
// could miss the second click
|
||||
host.addEventListener("dblclick", (e) => {
|
||||
const row = e.target.closest(".vt-row");
|
||||
if (row) this.onOpen(this.view[Number(row.dataset.i)]);
|
||||
});
|
||||
new ResizeObserver(() => this.layout()).observe(host);
|
||||
host.tabIndex = 0;
|
||||
host.addEventListener("keydown", (e) => this.key(e));
|
||||
this.layout();
|
||||
}
|
||||
|
||||
// ---------- column widths ----------
|
||||
loadWidths() {
|
||||
if (!this.id) return;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(`vt:${this.id}`) || "{}");
|
||||
for (const c of this.columns) if (saved[c.key] > 20) c.width = saved[c.key];
|
||||
} catch {}
|
||||
}
|
||||
saveWidths() {
|
||||
if (!this.id) return;
|
||||
try {
|
||||
localStorage.setItem(`vt:${this.id}`, JSON.stringify(Object.fromEntries(this.columns.map((c) => [c.key, c.width]))));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Column widths in px, the grow column stretched to fill the view. */
|
||||
widths() {
|
||||
const base = this.columns.map((c) => c.width);
|
||||
const total = base.reduce((a, b) => a + b, 0);
|
||||
const free = Math.max(0, this.host.clientWidth - total);
|
||||
const g = this.columns.findIndex((c) => c.grow);
|
||||
if (g >= 0) base[g] += free;
|
||||
return base;
|
||||
}
|
||||
|
||||
layout() {
|
||||
const w = this.widths();
|
||||
this.totalWidth = w.reduce((a, b) => a + b, 0);
|
||||
this.head.style.width = this.body.style.width = `${this.totalWidth}px`;
|
||||
this.columns.forEach((c, i) => (c.headEl.style.width = `${w[i]}px`));
|
||||
this.draw();
|
||||
}
|
||||
|
||||
startResize(e, col) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const start = col.width;
|
||||
document.body.classList.add("col-resizing");
|
||||
const move = (ev) => {
|
||||
col.width = Math.max(40, Math.round(start + ev.clientX - startX));
|
||||
this.layout();
|
||||
};
|
||||
const up = () => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.classList.remove("col-resizing");
|
||||
this.saveWidths();
|
||||
};
|
||||
document.addEventListener("mousemove", move);
|
||||
document.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
/** Double-click on a column edge: fit the widest visible value. */
|
||||
autoFit(col) {
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "vt-measure";
|
||||
document.body.append(probe);
|
||||
let max = col.label.length * 8 + 30;
|
||||
for (const r of this.view.slice(0, 2000)) {
|
||||
const v = col.format ? col.format(r) : r[col.key];
|
||||
probe.textContent = v instanceof Node ? v.textContent : String(v ?? "");
|
||||
max = Math.max(max, probe.offsetWidth + 34);
|
||||
}
|
||||
probe.remove();
|
||||
col.width = Math.min(1200, max);
|
||||
this.layout();
|
||||
this.saveWidths();
|
||||
}
|
||||
|
||||
// ---------- data ----------
|
||||
setRows(rows, { keepSelection = false, animate = null } = {}) {
|
||||
this.rows = rows;
|
||||
if (!keepSelection) {
|
||||
this.sel.clear();
|
||||
this.anchor = -1;
|
||||
} else {
|
||||
const keys = new Set(rows.map(this.keyOf));
|
||||
for (const k of [...this.sel]) if (!keys.has(k)) this.sel.delete(k);
|
||||
}
|
||||
if (animate) {
|
||||
this.host.scrollTop = 0;
|
||||
this.body.classList.remove("in-left", "in-right", "in-fade");
|
||||
void this.body.offsetWidth;
|
||||
this.body.classList.add(`in-${animate}`);
|
||||
}
|
||||
this.applySort();
|
||||
}
|
||||
|
||||
setView(filterFn) {
|
||||
this.filter = filterFn;
|
||||
this.applySort();
|
||||
}
|
||||
|
||||
sortBy(key) {
|
||||
if (this.sortKey === key) this.sortDesc = !this.sortDesc;
|
||||
else {
|
||||
this.sortKey = key;
|
||||
this.sortDesc = false;
|
||||
}
|
||||
for (const c of this.columns) {
|
||||
c.headEl.classList.toggle("sorted", c.key === this.sortKey);
|
||||
c.headEl.classList.toggle("desc", c.key === this.sortKey && this.sortDesc);
|
||||
}
|
||||
this.applySort();
|
||||
}
|
||||
|
||||
applySort() {
|
||||
const v = this.filter ? this.rows.filter(this.filter) : this.rows.slice();
|
||||
const col = this.columns.find((c) => c.key === this.sortKey);
|
||||
const first = this.sortFirst;
|
||||
if (col || first) {
|
||||
const get = col ? col.sort || ((r) => r[col.key]) : null;
|
||||
v.sort((a, b) => {
|
||||
if (first) {
|
||||
const f = first(a) - first(b);
|
||||
if (f) return f;
|
||||
}
|
||||
if (!get) return 0;
|
||||
const x = get(a);
|
||||
const y = get(b);
|
||||
const d = typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y));
|
||||
return this.sortDesc ? -d : d;
|
||||
});
|
||||
}
|
||||
this.view = v;
|
||||
this.body.style.height = `${v.length * this.rh}px`;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.totalWidth) return;
|
||||
const w = this.widths();
|
||||
const top = this.host.scrollTop;
|
||||
const height = this.host.clientHeight;
|
||||
const first = Math.max(0, Math.floor(top / this.rh) - 8);
|
||||
const last = Math.min(this.view.length, Math.ceil((top + height) / this.rh) + 8);
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = first; i < last; i++) {
|
||||
const r = this.view[i];
|
||||
const row = document.createElement("div");
|
||||
row.className = "vt-row" + (this.sel.has(this.keyOf(r)) ? " sel" : "") + (this.rowClass ? ` ${this.rowClass(r)}` : "");
|
||||
row.style.top = `${i * this.rh}px`;
|
||||
row.style.width = `${this.totalWidth}px`;
|
||||
row.dataset.i = i;
|
||||
this.columns.forEach((c, ci) => {
|
||||
const cell = document.createElement("div");
|
||||
cell.style.width = `${w[ci]}px`;
|
||||
if (c.cls) cell.className = c.cls;
|
||||
const v = c.format ? c.format(r) : r[c.key];
|
||||
if (v instanceof Node) cell.append(v);
|
||||
else {
|
||||
cell.textContent = v ?? "";
|
||||
if (c.title) cell.title = cell.textContent;
|
||||
}
|
||||
row.append(cell);
|
||||
});
|
||||
row.onmousedown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
// e.g. a folder: one click opens it (Ctrl/Shift still select)
|
||||
if (!e.ctrlKey && !e.shiftKey && this.onActivate?.(r, e)) return;
|
||||
this.click(i, e);
|
||||
};
|
||||
frag.append(row);
|
||||
}
|
||||
this.body.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ---------- selection / keys ----------
|
||||
click(i, e) {
|
||||
const key = this.keyOf(this.view[i]);
|
||||
if (this.multi && e.shiftKey && this.anchor >= 0) {
|
||||
if (!e.ctrlKey) this.sel.clear();
|
||||
const [a, b] = [Math.min(this.anchor, i), Math.max(this.anchor, i)];
|
||||
for (let k = a; k <= b; k++) this.sel.add(this.keyOf(this.view[k]));
|
||||
} else if (this.multi && e.ctrlKey) {
|
||||
if (this.sel.has(key)) this.sel.delete(key);
|
||||
else this.sel.add(key);
|
||||
this.anchor = i;
|
||||
} else {
|
||||
this.sel.clear();
|
||||
this.sel.add(key);
|
||||
this.anchor = i;
|
||||
}
|
||||
this.markSelection();
|
||||
this.onSelect(this.selected(), this.view[i]);
|
||||
}
|
||||
|
||||
/** Updates the highlight on the rows already on screen (keeps the elements, so double-click works). */
|
||||
markSelection() {
|
||||
for (const row of this.body.children) row.classList.toggle("sel", this.sel.has(this.keyOf(this.view[Number(row.dataset.i)])));
|
||||
}
|
||||
|
||||
key(e) {
|
||||
if (e.ctrlKey && e.key.toLowerCase() === "a" && this.multi) {
|
||||
e.preventDefault();
|
||||
for (const r of this.view) this.sel.add(this.keyOf(r));
|
||||
this.markSelection();
|
||||
this.onSelect(this.selected());
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!this.view.length) return;
|
||||
const i = Math.max(0, Math.min(this.view.length - 1, this.anchor + (e.key === "ArrowDown" ? 1 : -1)));
|
||||
this.click(i, { shiftKey: e.shiftKey, ctrlKey: false });
|
||||
const y = i * this.rh;
|
||||
if (y < this.host.scrollTop + 30) this.host.scrollTop = y - 30;
|
||||
else if (y > this.host.scrollTop + this.host.clientHeight - this.rh * 2) this.host.scrollTop = y - this.host.clientHeight + this.rh * 2;
|
||||
} else if (e.key === "Enter" && this.anchor >= 0) this.onOpen(this.view[this.anchor]);
|
||||
else if (e.key === "Backspace") this.onBack?.();
|
||||
}
|
||||
|
||||
selected() {
|
||||
return this.rows.filter((r) => this.sel.has(this.keyOf(r)));
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.sel.clear();
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
window.VTable = VTable;
|
||||
@@ -0,0 +1,227 @@
|
||||
"use strict";
|
||||
|
||||
// Source-preserving XML: parses into elements that remember where every attribute value and text
|
||||
// sits in the original string, so edits replace just those characters (comments, spacing, line
|
||||
// breaks, CP949 bytes — the text travels as latin1 — all stay as they were). After an edit the
|
||||
// document is re-parsed (UI files are small).
|
||||
|
||||
const XmlDoc = (() => {
|
||||
const isName = (c) => /[A-Za-z0-9_:.\-]/.test(c);
|
||||
|
||||
/** → { src, root, elements } ; element = { tag, attrs: [{ name, value, vs, ve, ns }], start, openEnd, selfClose, end, children, parent, textStart, textEnd } */
|
||||
function parse(src) {
|
||||
const els = [];
|
||||
const stack = [];
|
||||
const warnings = [];
|
||||
let root = null;
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const err = (msg) => {
|
||||
const line = src.slice(0, i).split("\n").length;
|
||||
throw new Error(`XML line ${line}: ${msg}`);
|
||||
};
|
||||
while (i < n) {
|
||||
const lt = src.indexOf("<", i);
|
||||
if (lt < 0) break;
|
||||
const top = stack[stack.length - 1];
|
||||
if (top && top.textStart === undefined && lt > i) {
|
||||
// text between tags (only kept for leaf elements such as <Style>, <Text>)
|
||||
top._text = (top._text || []).concat([[i, lt]]);
|
||||
}
|
||||
i = lt;
|
||||
if (src.startsWith("<!--", i)) {
|
||||
const e = src.indexOf("-->", i + 4);
|
||||
i = e < 0 ? n : e + 3;
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("<?", i)) {
|
||||
const e = src.indexOf("?>", i + 2);
|
||||
i = e < 0 ? n : e + 2;
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("<![CDATA[", i)) {
|
||||
const e = src.indexOf("]]>", i);
|
||||
i = e < 0 ? n : e + 3;
|
||||
continue;
|
||||
}
|
||||
if (src.startsWith("<!", i)) {
|
||||
const e = src.indexOf(">", i);
|
||||
i = e < 0 ? n : e + 1;
|
||||
continue;
|
||||
}
|
||||
if (src[i + 1] === "/") {
|
||||
// closing tag
|
||||
let j = i + 2;
|
||||
while (j < n && isName(src[j])) j++;
|
||||
const tag = src.slice(i + 2, j);
|
||||
const gt = src.indexOf(">", j);
|
||||
if (gt < 0) err("unclosed end tag");
|
||||
// a stray / mismatched end tag: close up to the matching element, or ignore it
|
||||
const at = stack.map((e) => e.tag.toLowerCase()).lastIndexOf(tag.toLowerCase());
|
||||
if (at < 0) {
|
||||
warnings.push(`line ${src.slice(0, i).split("\n").length}: stray </${tag}> ignored`);
|
||||
i = gt + 1;
|
||||
continue;
|
||||
}
|
||||
while (stack.length - 1 > at) {
|
||||
const open = stack.pop();
|
||||
delete open._text;
|
||||
open.closeStart = i;
|
||||
open.end = i;
|
||||
open.unclosed = true;
|
||||
warnings.push(`<${open.tag}> closed by </${tag}>`);
|
||||
}
|
||||
const el = stack.pop();
|
||||
if (!el.children.length && el._text) {
|
||||
el.textStart = el._text[0][0];
|
||||
el.textEnd = el._text[el._text.length - 1][1];
|
||||
}
|
||||
delete el._text;
|
||||
el.closeStart = i;
|
||||
el.end = gt + 1;
|
||||
i = gt + 1;
|
||||
continue;
|
||||
}
|
||||
// opening tag
|
||||
let j = i + 1;
|
||||
while (j < n && isName(src[j])) j++;
|
||||
const el = { tag: src.slice(i + 1, j), attrs: [], start: i, children: [], parent: stack[stack.length - 1] || null };
|
||||
for (;;) {
|
||||
while (j < n && /\s/.test(src[j])) j++;
|
||||
if (src[j] === "/" && src[j + 1] === ">") {
|
||||
el.selfClose = true;
|
||||
el.openEnd = j;
|
||||
el.end = j + 2;
|
||||
j += 2;
|
||||
break;
|
||||
}
|
||||
if (src[j] === ">") {
|
||||
el.openEnd = j;
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
const ns = j;
|
||||
while (j < n && isName(src[j])) j++;
|
||||
if (j === ns) {
|
||||
warnings.push(`line ${src.slice(0, j).split("\n").length}: stray “${src[j]}” in <${el.tag}>`);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
const name = src.slice(ns, j);
|
||||
while (j < n && /\s/.test(src[j])) j++;
|
||||
if (src[j] !== "=") {
|
||||
el.attrs.push({ name, value: "", vs: j, ve: j, ns, bare: true });
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
while (j < n && /\s/.test(src[j])) j++;
|
||||
const q = src[j];
|
||||
if (q !== '"' && q !== "'") {
|
||||
// unquoted value (a few game files have them; the client's parser takes them)
|
||||
const vs = j;
|
||||
while (j < n && !/[\s>]/.test(src[j]) && !(src[j] === "/" && src[j + 1] === ">")) j++;
|
||||
el.attrs.push({ name, value: src.slice(vs, j), vs, ve: j, ns, unquoted: true });
|
||||
continue;
|
||||
}
|
||||
const ve = src.indexOf(q, j + 1);
|
||||
if (ve < 0) err(`unclosed attribute ${name}`);
|
||||
el.attrs.push({ name, value: src.slice(j + 1, ve), vs: j + 1, ve, ns });
|
||||
j = ve + 1;
|
||||
}
|
||||
els.push(el);
|
||||
if (el.parent) el.parent.children.push(el);
|
||||
else if (!root) root = el;
|
||||
if (!el.selfClose) stack.push(el);
|
||||
i = j;
|
||||
}
|
||||
// unclosed elements at the end (the client tolerates them): closed at the end of the file
|
||||
const unclosed = stack.map((e) => e.tag);
|
||||
while (stack.length) {
|
||||
const el = stack.pop();
|
||||
delete el._text;
|
||||
el.closeStart = n;
|
||||
el.end = n;
|
||||
el.unclosed = true;
|
||||
}
|
||||
if (unclosed.length) warnings.push(`never closed: <${unclosed.join("> <")}>`);
|
||||
return { src, root, elements: els, warnings };
|
||||
}
|
||||
|
||||
const attr = (el, name) => el?.attrs.find((a) => a.name.toLowerCase() === name.toLowerCase())?.value;
|
||||
const num = (el, name, d = 0) => {
|
||||
const v = parseFloat(attr(el, name));
|
||||
return isFinite(v) ? v : d;
|
||||
};
|
||||
const text = (doc, el) => (el && el.textStart !== undefined ? doc.src.slice(el.textStart, el.textEnd) : "");
|
||||
const child = (el, tag) => el?.children.find((c) => c.tag.toLowerCase() === tag.toLowerCase()) || null;
|
||||
const childrenOf = (el, tag) => (el ? el.children.filter((c) => c.tag.toLowerCase() === tag.toLowerCase()) : []);
|
||||
const escAttr = (v) => String(v).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
|
||||
/** The edits: each returns the new source string. */
|
||||
function setAttr(doc, el, name, value) {
|
||||
const a = el.attrs.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
||||
const v = escAttr(value);
|
||||
if (a && a.unquoted) return doc.src.slice(0, a.vs) + `"${v}"` + doc.src.slice(a.ve);
|
||||
if (a && !a.bare) return doc.src.slice(0, a.vs) + v + doc.src.slice(a.ve);
|
||||
const at = el.openEnd;
|
||||
const before = doc.src.slice(0, at).replace(/\s*$/, "");
|
||||
const cut = before.length;
|
||||
return doc.src.slice(0, cut) + ` ${name}="${v}"` + (el.selfClose ? " " : "") + doc.src.slice(el.selfClose ? at : at);
|
||||
}
|
||||
function removeAttr(doc, el, name) {
|
||||
const a = el.attrs.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
||||
if (!a) return doc.src;
|
||||
let s = a.ns;
|
||||
while (s > 0 && /[ \t]/.test(doc.src[s - 1])) s--;
|
||||
const e = a.bare || a.unquoted ? a.ve : a.ve + 1;
|
||||
return doc.src.slice(0, s) + doc.src.slice(e);
|
||||
}
|
||||
function setText(doc, el, value) {
|
||||
if (el.textStart !== undefined) return doc.src.slice(0, el.textStart) + value + doc.src.slice(el.textEnd);
|
||||
if (el.selfClose) return doc.src.slice(0, el.openEnd).replace(/\s*$/, "") + `>${value}</${el.tag}>` + doc.src.slice(el.end);
|
||||
return doc.src.slice(0, el.openEnd + 1) + value + doc.src.slice(el.openEnd + 1);
|
||||
}
|
||||
/** Line break + indentation used around an element. */
|
||||
function indentOf(doc, el) {
|
||||
const ls = doc.src.lastIndexOf("\n", el.start - 1);
|
||||
const ws = doc.src.slice(ls + 1, el.start);
|
||||
return /^[ \t]*$/.test(ws) ? ws : "";
|
||||
}
|
||||
const eolOf = (doc) => (doc.src.includes("\r\n") ? "\r\n" : "\n");
|
||||
/** Inserts markup as the last child of `parent` (or after `after`). */
|
||||
function insertChild(doc, parent, markup, after = null) {
|
||||
const eol = eolOf(doc);
|
||||
const base = indentOf(doc, parent);
|
||||
const step = parent.children[0] ? indentOf(doc, parent.children[0]).slice(base.length) || "\t" : "\t";
|
||||
const ind = base + step;
|
||||
const body = markup.split(/\r?\n/).map((l, k) => (k ? ind + l : l)).join(eol);
|
||||
if (after) return doc.src.slice(0, after.end) + eol + ind + body + doc.src.slice(after.end);
|
||||
if (parent.selfClose) {
|
||||
const open = doc.src.slice(parent.start, parent.openEnd).replace(/\s*$/, "");
|
||||
return doc.src.slice(0, parent.start) + open + ">" + eol + ind + body + eol + base + `</${parent.tag}>` + doc.src.slice(parent.end);
|
||||
}
|
||||
// before the closing tag, on its own line
|
||||
let cs = parent.closeStart;
|
||||
let ls = cs;
|
||||
while (ls > 0 && /[ \t]/.test(doc.src[ls - 1])) ls--;
|
||||
return doc.src.slice(0, ls) + ind + body + eol + doc.src.slice(ls);
|
||||
}
|
||||
function removeElement(doc, el) {
|
||||
let s = el.start;
|
||||
let e = el.end;
|
||||
// take the whole line when the element sits alone on it
|
||||
let ls = s;
|
||||
while (ls > 0 && /[ \t]/.test(doc.src[ls - 1])) ls--;
|
||||
let le = e;
|
||||
while (le < doc.src.length && /[ \t]/.test(doc.src[le])) le++;
|
||||
if ((ls === 0 || doc.src[ls - 1] === "\n") && (doc.src[le] === "\r" || doc.src[le] === "\n")) {
|
||||
s = ls;
|
||||
e = le + (doc.src[le] === "\r" && doc.src[le + 1] === "\n" ? 2 : 1);
|
||||
}
|
||||
return doc.src.slice(0, s) + doc.src.slice(e);
|
||||
}
|
||||
const markupOf = (doc, el) => doc.src.slice(el.start, el.end);
|
||||
|
||||
return { parse, attr, num, text, child, childrenOf, setAttr, removeAttr, setText, insertChild, removeElement, markupOf, indentOf };
|
||||
})();
|
||||
window.XmlDoc = XmlDoc;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"order": [
|
||||
"vtable.js",
|
||||
"dds.js",
|
||||
"imageset.js",
|
||||
"lsformats.js",
|
||||
"gl3d.js",
|
||||
"meshkit.js",
|
||||
"skin.js",
|
||||
"effects.js",
|
||||
"effect-editor.js",
|
||||
"fxlab.js",
|
||||
"model.js",
|
||||
"animkit.js",
|
||||
"anim-studio.js",
|
||||
"costumekit.js",
|
||||
"costume.js",
|
||||
"texkit.js",
|
||||
"texture.js",
|
||||
"hero-ini.js",
|
||||
"hero.js",
|
||||
"mapedit.js",
|
||||
"xmldoc.js",
|
||||
"uires.js",
|
||||
"uiwnd.js",
|
||||
"items.js",
|
||||
"quests.js",
|
||||
"tables.js"
|
||||
],
|
||||
"views": [
|
||||
"ui",
|
||||
"wnd",
|
||||
"mdl",
|
||||
"ans",
|
||||
"cos",
|
||||
"fx",
|
||||
"fxlab",
|
||||
"hero",
|
||||
"tex",
|
||||
"map",
|
||||
"items",
|
||||
"quests",
|
||||
"tables"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<section id="view-ans" class="view">
|
||||
<header class="bar top">
|
||||
<button id="ans-src" class="iconbtn" title="Where skeletons, meshes and animations are read from"><i data-icon="box"></i><span id="ans-src-label">Choose packs…</span></button>
|
||||
<button id="ans-new" class="iconbtn" title="A new empty animation on the current character"><i data-icon="newfile"></i><span>New</span></button>
|
||||
<button id="ans-open-file" class="iconbtn" title="Open an .ani from disk (e.g. exported from Blender)"><i data-icon="folder"></i><span>Open .ani…</span></button>
|
||||
<div class="ims-title"><span id="ans-title" class="muted"></span><span id="ans-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="ans-undo" class="iconbtn only" title="Undo (Ctrl+Z)" disabled><i data-icon="undo"></i></button>
|
||||
<button id="ans-redo" class="iconbtn only" title="Redo (Ctrl+Y)" disabled><i data-icon="redo"></i></button>
|
||||
<button id="ans-export" class="iconbtn" title="Export"><i data-icon="download"></i><span>Export</span></button>
|
||||
<button id="ans-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<aside id="ans-left" class="ims-left">
|
||||
<div class="mdl-kinds">
|
||||
<div id="ans-kinds" class="seg">
|
||||
<button data-kind="model" class="on" title="Model configs: use as the character (skeleton + meshes)">Models</button>
|
||||
<button data-kind="skl" title="Skeletons: use as the character">Skel</button>
|
||||
<button data-kind="msh" title="Meshes: add to the character">Meshes</button>
|
||||
<button data-kind="ani" title="Animations: open, join, retarget">Anims</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="ans-search" type="search" placeholder="Search" spellcheck="false" /></div>
|
||||
<span id="ans-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="ans-list" class="mdl-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Double-click uses it · right-click for more</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="ans-left" data-edge="left" title="Drag to resize"></div>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<div id="ans-tools" class="seg">
|
||||
<button data-tool="view" class="on" title="View: drag turns the camera, click picks a bone (Esc)">View</button>
|
||||
<button data-tool="rotate" title="Rotate the selected bone: drag (Ctrl = twist around the view) (R)">Rotate</button>
|
||||
<button data-tool="move" title="Move the selected bone (mostly the root, Bip01): drag (G)">Move</button>
|
||||
</div>
|
||||
<button id="ans-t-mesh" class="iconbtn toggle on" title="Meshes"><i data-icon="cube"></i><span>Mesh</span></button>
|
||||
<button id="ans-t-bones" class="iconbtn toggle on" title="Bones"><i data-icon="bone"></i><span>Bones</span></button>
|
||||
<button id="ans-t-ghost" class="iconbtn toggle on" title="The source skeleton of an import / retarget, drawn where it lands"><i data-icon="swap"></i><span>Source</span></button>
|
||||
<button id="ans-t-grid" class="iconbtn toggle on only" title="Floor grid (y = 0)"><i data-icon="floor"></i></button>
|
||||
<div class="spacer"></div>
|
||||
<span id="ans-info" class="muted ims-texinfo"></span>
|
||||
<button id="ans-reset" class="iconbtn only" title="Reset the view"><i data-icon="focus"></i></button>
|
||||
</div>
|
||||
<div class="mdl-stage">
|
||||
<canvas id="ans-canvas" tabindex="0"></canvas>
|
||||
<div id="ans-labels" class="ans-labels"></div>
|
||||
<div id="ans-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
|
||||
</div>
|
||||
<div id="ans-transport" class="ans-transport">
|
||||
<button id="ans-play" class="iconbtn only" title="Play / pause (Space)"><i data-icon="play"></i></button>
|
||||
<span id="ans-clock" class="mono"></span>
|
||||
<select id="ans-speed" title="Playback speed">
|
||||
<option value="0.1">0.1×</option><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option>
|
||||
</select>
|
||||
<button id="ans-loop" class="iconbtn toggle on only" title="Loop"><i data-icon="refresh"></i></button>
|
||||
<span class="ans-sep"></span>
|
||||
<button id="ans-key" class="iconbtn" title="Key the selected bone at the playhead (K)"><i data-icon="plus"></i><span>Key</span></button>
|
||||
<button id="ans-key-all" class="iconbtn" title="Key every bone at the playhead (Shift+K)"><i data-icon="plus"></i><span>Key all</span></button>
|
||||
<button id="ans-del-keys" class="iconbtn" title="Delete the selected keys (Del)"><i data-icon="trash"></i></button>
|
||||
<div class="spacer"></div>
|
||||
<span id="ans-range-label" class="muted"></span>
|
||||
</div>
|
||||
<div id="ans-dope" class="ans-dope"><canvas id="ans-dope-canvas" tabindex="0"></canvas></div>
|
||||
<div id="ans-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="bone" class="big"></i>
|
||||
<h2>Anim Studio</h2>
|
||||
<p>Pick the packs, then double-click a <b>model</b> or <b>skeleton</b> for the character (heroes: <b>human_man</b> / <b>human_woman</b>) and an <b>animation</b> to edit — or <b>New</b> for an empty one.</p>
|
||||
<p class="muted">Click a bone, then Rotate / Move and drag; every change keys the bone at the playhead. Shift-drag the ruler for a time range.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="splitter" data-target="ans-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="ans-side" class="ims-side ans-side">
|
||||
<section class="ims-card">
|
||||
<h4>Character <span id="ans-char-name" class="muted"></span></h4>
|
||||
<div id="ans-char"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Clip <span id="ans-clip-name" class="muted"></span></h4>
|
||||
<div id="ans-clip"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Bone <span id="ans-bone-name" class="muted"></span></h4>
|
||||
<div id="ans-bone"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Cut & join</h4>
|
||||
<div id="ans-ops"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Events <span id="ans-ev-count" class="muted"></span></h4>
|
||||
<div id="ans-events"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Hitbox</h4>
|
||||
<div id="ans-hit"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Blender</h4>
|
||||
<div id="ans-blender"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,86 @@
|
||||
<section id="view-cos" class="view">
|
||||
<header class="bar top">
|
||||
<button id="cos-src" class="iconbtn" title="Where the hero body, meshes and animations are read from"><i data-icon="box"></i><span id="cos-src-label">Choose packs…</span></button>
|
||||
<button id="cos-import" class="iconbtn" title="Import a 3D model as the costume: .glb / .gltf (Blender: File → Export → glTF 2.0, with its armature if it has one), .fbx (binary) or .obj"><i data-icon="import"></i><span>Import 3D…</span></button>
|
||||
<button id="cos-blender" class="iconbtn" title="Save the body you see (skeleton, weights, textures) as a .glb to model on in Blender"><i data-icon="cube"></i><span>Body for Blender…</span></button>
|
||||
<div class="ims-title"><span id="cos-title" class="muted"></span><span id="cos-dirty" class="dot bad" title="Not saved yet" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="cos-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<aside id="cos-left" class="ims-left">
|
||||
<div class="mdl-kinds">
|
||||
<div id="cos-kinds" class="seg">
|
||||
<button data-kind="msh" class="on" title="Meshes: double-click puts it on the hero (costume bodies, hands, helmets, cloaks…)">Meshes</button>
|
||||
<button data-kind="ani" title="Animations: double-click plays it on the hero">Anims</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="cos-search" type="search" placeholder="Search (e.g. _body_)" spellcheck="false" /></div>
|
||||
<span id="cos-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="cos-list" class="mdl-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Double-click: wear the mesh / play the animation</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="cos-left" data-edge="left" title="Drag to resize"></div>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<div id="cos-sex" class="seg">
|
||||
<button data-sex="man" class="on" title="sp2_human_man: human_body, default_hand, face, hair">Man</button>
|
||||
<button data-sex="woman" title="sp2_human_woman: the _hw meshes (same skeleton)">Woman</button>
|
||||
</div>
|
||||
<button id="cos-t-bones" class="iconbtn toggle only" title="Bones"><i data-icon="bone"></i></button>
|
||||
<button id="cos-t-grid" class="iconbtn toggle on only" title="Floor grid (y = 0)"><i data-icon="floor"></i></button>
|
||||
<button id="cos-t-wire" class="iconbtn toggle only" title="Wireframe"><i data-icon="grid"></i></button>
|
||||
<div class="spacer"></div>
|
||||
<span id="cos-info" class="muted ims-texinfo"></span>
|
||||
<button id="cos-reset" class="iconbtn only" title="Reset the view"><i data-icon="focus"></i></button>
|
||||
</div>
|
||||
<div class="mdl-stage">
|
||||
<canvas id="cos-canvas" tabindex="0"></canvas>
|
||||
<div id="cos-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
|
||||
</div>
|
||||
<div class="ans-transport">
|
||||
<button id="cos-play" class="iconbtn only" title="Play / pause (Space)"><i data-icon="play"></i></button>
|
||||
<select id="cos-anim" title="Animation"></select>
|
||||
<span id="cos-clock" class="mono"></span>
|
||||
<input id="cos-scrub" type="range" min="0" max="1000" value="0" class="grow" title="Time" />
|
||||
<button id="cos-tpose" class="iconbtn" title="Stop and show the rest pose (T-pose: where the costume is modelled)"><i data-icon="bone"></i><span>T-pose</span></button>
|
||||
</div>
|
||||
<div id="cos-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="shirt" class="big"></i>
|
||||
<h2>Costume Studio</h2>
|
||||
<p>Pick the packs: the hero body appears in its T-pose. <b>Import 3D…</b> a .glb / .gltf / .obj — it is fitted to the body, takes its bones' weights (or the body's) and moves with every animation.</p>
|
||||
<p class="muted">For an exact fit: <b>Body for Blender…</b>, model the clothes on that body in Blender (parent them to its armature with weights, or leave that to this tool), export glTF 2.0 and import it here. Save writes the .msh, its material and the .dds textures.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="splitter" data-target="cos-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="cos-side" class="ims-side ans-side">
|
||||
<section class="ims-card">
|
||||
<h4>Hero <span id="cos-char-name" class="muted"></span></h4>
|
||||
<div id="cos-char"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Costume <span id="cos-file-name" class="muted"></span></h4>
|
||||
<div id="cos-opts"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Materials</h4>
|
||||
<div id="cos-mats"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Result</h4>
|
||||
<div id="cos-report"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Register in game <span id="cos-reg-file" class="muted"></span></h4>
|
||||
<div id="cos-reg"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,38 @@
|
||||
<section id="view-fx" class="view">
|
||||
<header class="bar top">
|
||||
<button id="fx-src" class="iconbtn" title="Choose packs or extracted resources"><i data-icon="box"></i><span id="fx-src-label">Choose packs…</span></button>
|
||||
<button id="fx-open-file" class="iconbtn" title="Open an effect script from disk"><i data-icon="folder"></i><span>Open file…</span></button>
|
||||
<button id="fx-new" class="iconbtn" title="Create a new effect"><i data-icon="plus"></i><span>New</span></button>
|
||||
<div class="ims-title"><span id="fx-title" class="muted"></span><span id="fx-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="fx-to-lab" class="iconbtn" title="Edit this effect in FX Lab (sliders, curves, hero preview)"><i data-icon="brush"></i><span>Edit in FX Lab</span></button>
|
||||
<button id="fx-save-as" class="iconbtn" disabled><i data-icon="download"></i><span>Save As…</span></button>
|
||||
<button id="fx-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
<div class="split">
|
||||
<aside id="fx-left" class="ims-left">
|
||||
<div class="ims-head"><div class="search"><i data-icon="search"></i><input id="fx-search" type="search" placeholder="Search effects" spellcheck="false" /></div><span id="fx-count" class="muted"></span></div>
|
||||
<div id="fx-list" class="mdl-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Double-click an effect to open it</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="fx-left" data-edge="left" title="Drag to resize"></div>
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<button id="fx-play" class="iconbtn toggle on" title="Play or pause"><i data-icon="play"></i><span>Play</span></button>
|
||||
<button id="fx-restart" class="iconbtn" title="Restart preview"><i data-icon="refresh"></i><span>Restart</span></button>
|
||||
<button id="fx-grid" class="iconbtn toggle on" title="Floor grid"><i data-icon="floor"></i><span>Grid</span></button>
|
||||
<div class="spacer"></div><span id="fx-stats" class="mono muted"></span>
|
||||
<button id="fx-reset" class="iconbtn only" title="Reset camera"><i data-icon="focus"></i></button>
|
||||
</div>
|
||||
<div class="mdl-stage fx-stage"><canvas id="fx-canvas" tabindex="0"></canvas></div>
|
||||
<div id="fx-empty" class="empty"><div class="empty-card"><i data-icon="zap" class="big"></i><h2>Effect editor</h2><p>Choose the game's packs or an extracted resource folder, then open an effect. Drag to orbit, right-drag to pan, wheel to zoom.</p></div></div>
|
||||
<div class="ims-help muted">Select a system in the hierarchy to set each particle or model separately. Particle preview uses the Map editor simulator; model and light preview is not available yet.</div>
|
||||
</div>
|
||||
<div class="splitter" data-target="fx-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="fx-side" class="ims-side fx-side">
|
||||
<section class="ims-card"><h4>Hierarchy</h4><div id="fx-hierarchy" class="fx-hierarchy"></div><div class="fx-actions"><button id="fx-add-particle" class="iconbtn"><i data-icon="plus"></i>Particle</button><button id="fx-add-model" class="iconbtn"><i data-icon="plus"></i>Model</button><button id="fx-add-emitter" class="iconbtn"><i data-icon="plus"></i>Emitter</button><button id="fx-add-affector" class="iconbtn"><i data-icon="plus"></i>Affector</button><button id="fx-duplicate-block" class="iconbtn" title="Duplicate selected particle or model"><i data-icon="copy"></i></button><button id="fx-move-up" class="iconbtn" title="Move selected particle or model up"><i data-icon="up"></i></button><button id="fx-move-down" class="iconbtn" title="Move selected particle or model down"><i data-icon="down"></i></button><button id="fx-delete-block" class="iconbtn danger" title="Remove selected block"><i data-icon="trash"></i></button></div></section>
|
||||
<section class="ims-card"><h4>Properties <span id="fx-selection" class="muted"></span></h4><div id="fx-properties" class="fx-properties"></div><div class="fx-actions"><button id="fx-add-field" class="iconbtn"><i data-icon="plus"></i>Field</button></div></section>
|
||||
<section class="ims-card fx-script-card"><h4>Effect script <span id="fx-parse-status" class="muted"></span></h4><textarea id="fx-script" class="fx-script" spellcheck="false" aria-label="Effect script"></textarea></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
<section id="view-fxlab" class="view">
|
||||
<header class="bar top">
|
||||
<button id="fxl-src" class="iconbtn" title="Game packs used for pack textures and the hero preview"><i data-icon="box"></i><span id="fxl-src-label">Choose packs…</span></button>
|
||||
<button id="fxl-new" class="iconbtn" title="Start an empty effect"><i data-icon="plus"></i><span>New</span></button>
|
||||
<input id="fxl-name" class="fxl-name" type="text" spellcheck="false" value="my_effect" title="Effect name (file name of the export)" />
|
||||
<button id="fxl-save-preset" class="iconbtn" title="Save as a preset (FX Lab library)"><i data-icon="save"></i><span>Save preset</span></button>
|
||||
<button id="fxl-open" class="iconbtn" title="Open an existing Lost Saga effect (packs or file) or a preset JSON"><i data-icon="folder"></i><span>Open…</span></button>
|
||||
<button id="fxl-export-json" class="iconbtn" title="Export the preset as JSON"><i data-icon="download"></i><span>JSON</span></button>
|
||||
<div class="spacer"></div>
|
||||
<button id="fxl-export-sheet" class="iconbtn" title="Render the free preview to a sprite sheet PNG + Aseprite-style JSON"><i data-icon="image"></i><span>Sprite sheet…</span></button>
|
||||
<button id="fxl-save" class="iconbtn" title="Save the script back to the effect it was opened from" hidden><i data-icon="save"></i><span id="fxl-save-label">Save</span></button>
|
||||
<button id="fxl-export-ls" class="btn primary" title="Write resource/effect/<name>.txt and its textures (DDS) to a folder"><i data-icon="upload"></i>Export LS effect…</button>
|
||||
</header>
|
||||
<div class="split">
|
||||
<aside id="fxl-left" class="ims-left">
|
||||
<div class="ims-head"><div class="search"><i data-icon="search"></i><input id="fxl-search" type="search" placeholder="Search presets" spellcheck="false" /></div></div>
|
||||
<div id="fxl-presets" class="ims-list fxl-presets"></div>
|
||||
<div class="ims-foot muted mdl-hint">Click a preset to load it</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="fxl-left" data-edge="left" title="Drag to resize"></div>
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<button id="fxl-play" class="iconbtn toggle on" title="Play or pause"><i data-icon="play"></i><span>Play</span></button>
|
||||
<button id="fxl-restart" class="iconbtn only" title="Restart (fire the effect again)"><i data-icon="refresh"></i></button>
|
||||
<button id="fxl-grid" class="iconbtn only toggle on" title="Floor grid"><i data-icon="floor"></i></button>
|
||||
<select id="fxl-mode" title="Where the effect plays">
|
||||
<option value="free">Free (no hero)</option>
|
||||
<option value="bone">On hero (bone)</option>
|
||||
<option value="hit">Hit target</option>
|
||||
</select>
|
||||
<select id="fxl-anim" title="Hero animation played with the effect" hidden></select>
|
||||
<select id="fxl-bg" title="Background">
|
||||
<option value="gray">Gray</option><option value="dark">Dark</option><option value="black">Black</option>
|
||||
</select>
|
||||
<div class="spacer"></div><span id="fxl-stats" class="mono muted"></span>
|
||||
<button id="fxl-reset" class="iconbtn only" title="Reset camera"><i data-icon="focus"></i></button>
|
||||
</div>
|
||||
<div id="fxl-stage" class="mdl-stage fx-stage"><canvas id="fxl-canvas" tabindex="0"></canvas></div>
|
||||
<div class="ims-help muted">Pick a preset, then tune the selected layer on the right. Drag to orbit, right-drag to pan, wheel to zoom. Curves: drag points, double-click to add, right-click to remove.</div>
|
||||
</div>
|
||||
<div class="splitter" data-target="fxl-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="fxl-side" class="ims-side fx-side fxl-side">
|
||||
<section class="ims-card"><h4>Layers</h4><div id="fxl-layers" class="fxl-layers"></div>
|
||||
<details id="fxl-notes" class="fxl-notes" hidden></details>
|
||||
<div class="fx-actions"><button id="fxl-add-layer" class="iconbtn"><i data-icon="plus"></i>Layer</button><button id="fxl-dup-layer" class="iconbtn" title="Duplicate layer"><i data-icon="copy"></i></button><button id="fxl-up-layer" class="iconbtn" title="Move up"><i data-icon="up"></i></button><button id="fxl-del-layer" class="iconbtn danger" title="Remove layer"><i data-icon="trash"></i></button></div></section>
|
||||
<section class="ims-card fxl-props-card"><h4>Layer <span id="fxl-layer-name" class="muted"></span></h4><div id="fxl-props" class="fxl-props"></div></section>
|
||||
<section class="ims-card"><details><summary class="muted">Lost Saga script (generated)</summary><textarea id="fxl-script" class="fx-script fxl-script" spellcheck="false" readonly></textarea></details></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,38 @@
|
||||
<section id="view-hero" class="view">
|
||||
<header class="bar top">
|
||||
<button id="hero-src" class="iconbtn"><i data-icon="box"></i><span id="hero-src-label">Choose packs…</span></button>
|
||||
<div class="ims-title"><span id="hero-title" class="muted">Hero Creator · experimental</span><span id="hero-dirty" class="dot bad" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="hero-export" class="btn primary" disabled><i data-icon="download"></i>Export config…</button>
|
||||
</header>
|
||||
<div class="split">
|
||||
<aside id="hero-left" class="ims-left">
|
||||
<div class="ims-head"><div class="search"><i data-icon="search"></i><input id="hero-search" type="search" placeholder="Search heroes" spellcheck="false" /></div><span id="hero-count" class="muted"></span></div>
|
||||
<div id="hero-list" class="hero-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Select a hero as the base</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="hero-left" data-edge="left" title="Drag to resize"></div>
|
||||
<div class="main-col hero-main">
|
||||
<div id="hero-empty" class="empty"><div class="empty-card"><i data-icon="cube" class="big"></i><h2>Hero Creator</h2><p>Choose the client packs and a hero. Customize gear meshes, moves, skills and effects, then export changed INI files for testing.</p></div></div>
|
||||
<div id="hero-work" hidden>
|
||||
<div class="hero-summary"><h2 id="hero-name"></h2><p id="hero-summary" class="muted"></p></div>
|
||||
<div class="hero-studio">
|
||||
<div id="hero-stage" class="hero-stage" tabindex="0"><div id="hero-action-label" class="hero-action-label">Select an action to preview</div><div id="hero-practice-hud" class="hero-practice-hud" hidden><b>TRAINING</b><span id="hero-dummy-health">Dummy 100 / 100</span></div></div>
|
||||
<div class="hero-playbar"><button id="hero-idle" class="iconbtn">Idle pose</button><button id="hero-stop" class="iconbtn">Stop</button><button id="hero-practice" class="iconbtn" disabled>Training mode</button><span id="hero-play-status" class="muted"></span></div>
|
||||
<div id="hero-practice-help" class="hero-practice-help" hidden>↑ ↓ ← → move · double tap or Shift dash · A jump · S guard · D attack / hold D half attack · SD / ASD / AD / AS skills · 1–4 skill shortcuts · Esc exit</div>
|
||||
<div id="hero-combos" class="hero-combos"></div>
|
||||
<div id="hero-skills" class="hero-skills"></div>
|
||||
<div id="hero-action-info" class="hero-action-info muted"></div>
|
||||
</div>
|
||||
<div class="hero-tabs seg"><button data-hero-tab="gear" class="on">Gear</button><button data-hero-tab="moves">Moveset</button><button data-hero-tab="skills">Skills</button><button data-hero-tab="effects">Effects</button><button data-hero-tab="flow" title="Every attack: input → animation events → hits → attack attributes → effects / sounds / buffs, with the checks">Flow</button></div>
|
||||
<div id="hero-content" class="hero-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="splitter" data-target="hero-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="hero-side" class="ims-side hero-side">
|
||||
<section class="ims-card"><h4>Preview</h4><p class="muted">Play combos and skills here using the animations and effect references in the hero config.</p><button id="hero-preview" class="btn" disabled>Reload hero preview</button><div id="hero-preview-status" class="muted"></div></section>
|
||||
<section class="ims-card"><h4>Selected field</h4><div id="hero-field-detail" class="muted">Select a field to edit its exact INI value.</div></section>
|
||||
<section class="ims-card"><h4>Export status</h4><div id="hero-export-status" class="muted">Only changed INI files will be exported. The game packs remain untouched.</div></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,16 @@
|
||||
<section id="view-items" class="view">
|
||||
<header class="bar top">
|
||||
<div class="zone-title"><h2>Items</h2><span id="it-source" class="muted"></span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="search"><i data-icon="search"></i><input id="it-search" type="search" placeholder="Name, ID, icon or section" spellcheck="false" /></div>
|
||||
<label class="chip"><input id="it-hide" type="checkbox" checked /><span>Hide unnamed</span></label>
|
||||
<select id="it-text" title="Text table (language) the names come from"></select>
|
||||
<button id="it-folder" class="iconbtn" title="Read the items from another game folder"><i data-icon="folder"></i><span>Folder</span></button>
|
||||
<button id="it-reload" class="iconbtn" title="Read the packs again (after a patch or an edit)"><i data-icon="refresh"></i><span>Reload</span></button>
|
||||
</header>
|
||||
<div class="it-body">
|
||||
<nav id="it-cats" class="it-cats"></nav>
|
||||
<div class="it-list"><div id="it-table" class="vtable"></div><div id="it-count" class="muted it-count"></div></div>
|
||||
<aside id="it-detail" class="it-detail"><div class="it-empty muted">Pick an item.</div></aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,87 @@
|
||||
<section id="view-map" class="view">
|
||||
<header class="bar top">
|
||||
<button id="map-src" class="iconbtn" title="Where the maps, meshes and textures are read from"><i data-icon="box"></i><span id="map-src-label">Choose packs…</span></button>
|
||||
<button id="map-open-file" class="iconbtn" title="Open a map .ini from disk"><i data-icon="folder"></i><span>Open file…</span></button>
|
||||
<div class="ims-title"><span id="map-title" class="muted"></span><span id="map-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="map-add" class="iconbtn" title="Add an object, effect or point"><i data-icon="plus"></i><span>Add</span></button>
|
||||
<button id="map-undo" class="iconbtn only" title="Undo (Ctrl+Z)" disabled><i data-icon="undo"></i></button>
|
||||
<button id="map-redo" class="iconbtn only" title="Redo (Ctrl+Y)" disabled><i data-icon="redo"></i></button>
|
||||
<button id="map-save" class="btn primary"><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<aside id="map-left" class="ims-left">
|
||||
<div class="ims-head map-head">
|
||||
<select id="map-mode" title="Mode"><option value="">All modes</option></select>
|
||||
</div>
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="map-search" type="search" placeholder="Search maps / models" spellcheck="false" /></div>
|
||||
<span id="map-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="map-list" class="mdl-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Double-click a map to open it</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="map-left" data-edge="left" title="Drag to resize"></div>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<button id="map-t-anim" class="iconbtn toggle on" title="Map animation: moving textures (water, sky, glow), animated objects, elevators — off shows the map at rest"><i data-icon="play"></i><span>Anim</span></button>
|
||||
<button id="map-t-light" class="iconbtn toggle on" title="Lighting, fog and background from the config"><i data-icon="zap"></i><span>Light</span></button>
|
||||
<button id="map-t-tex" class="iconbtn toggle on only" title="Textures"><i data-icon="image"></i></button>
|
||||
<button id="map-t-wire" class="iconbtn toggle only" title="Wireframe"><i data-icon="grid"></i></button>
|
||||
<button id="map-t-col" class="iconbtn toggle" title="Collision mesh (.cms): where players can stand"><i data-icon="shield"></i><span>Collision</span></button>
|
||||
<button id="map-t-outline" class="iconbtn toggle on" title="Outline edges (.edg)"><i data-icon="draw"></i><span>Outline</span></button>
|
||||
<button id="map-t-objects" class="iconbtn toggle on" title="Objects placed by the config"><i data-icon="cube"></i><span>Objects</span></button>
|
||||
<button id="map-t-labels" class="iconbtn toggle" title="Names of every point"><i data-icon="note"></i><span>Labels</span></button>
|
||||
<button id="map-t-grid" class="iconbtn toggle only" title="Floor grid"><i data-icon="floor"></i></button>
|
||||
<label class="pill" title="Snap dragged points to a grid"><i data-icon="target"></i>
|
||||
<select id="map-snap"><option value="0">No snap</option><option value="10">10</option><option value="25">25</option><option value="50">50</option><option value="100">100</option></select>
|
||||
</label>
|
||||
<div class="spacer"></div>
|
||||
<span id="map-hover" class="mono ims-texinfo"></span>
|
||||
<button id="map-top" class="iconbtn only" title="Top view (T)"><i data-icon="up"></i></button>
|
||||
<button id="map-reset" class="iconbtn only" title="Reset the view"><i data-icon="focus"></i></button>
|
||||
</div>
|
||||
<div class="mdl-stage">
|
||||
<canvas id="map-canvas" tabindex="0"></canvas>
|
||||
<div id="map-labels" class="map-labels"></div>
|
||||
<div id="map-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
|
||||
</div>
|
||||
<div id="map-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="map" class="big"></i>
|
||||
<h2>Map editor</h2>
|
||||
<p>Pick the packs (<b>Choose packs…</b>: the client folder with rs.iop and map.iop), then double-click a map. Select an object and use <b>G</b> to move, <b>R</b> to rotate its editor preview, or <b>S</b> to scale. Drag points to move; Alt-drag changes height. Editor rotation is saved in a comment so it returns when reopened, but the game ignores it because map files have no rotation field.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ims-help muted">Click a point to select · P move · R rotate preview · S scale · Snap base to map floor · Alt-drag: height · arrows nudge · Ctrl+D copy · Del remove · F focus · T top view</div>
|
||||
</div>
|
||||
|
||||
<div class="splitter" data-target="map-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="map-side" class="ims-side map-side" hidden>
|
||||
<section class="ims-card">
|
||||
<h4>Map</h4>
|
||||
<div id="map-info" class="ims-form"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Layers</h4>
|
||||
<div id="map-layers" class="map-layers"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Objects <span id="map-object-count" class="muted">0</span></h4>
|
||||
<div class="search"><i data-icon="search"></i><input id="map-object-search" type="search" placeholder="Search objects / models" spellcheck="false" /></div>
|
||||
<div id="map-object-list" class="map-object-list"></div>
|
||||
</section>
|
||||
<section class="ims-card">
|
||||
<h4>Sections <span id="map-sec-count" class="muted"></span></h4>
|
||||
<div class="search"><i data-icon="search"></i><input id="map-sec-search" type="search" placeholder="Filter sections / values" spellcheck="false" /></div>
|
||||
<div id="map-sections" class="map-sections"></div>
|
||||
</section>
|
||||
<section id="map-sec-card" class="ims-card grow" hidden>
|
||||
<h4><span id="map-sec-name"></span></h4>
|
||||
<div id="map-sec"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,85 @@
|
||||
<section id="view-mdl" class="view">
|
||||
<header class="bar top">
|
||||
<button id="mdl-src" class="iconbtn" title="Where models, textures and animations are read from"><i data-icon="box"></i><span id="mdl-src-label">Choose packs…</span></button>
|
||||
<button id="mdl-open-file" class="iconbtn" title="Open a .msh, .ani, .skl or model .txt from disk"><i data-icon="folder"></i><span>Open file…</span></button>
|
||||
<div class="ims-title"><span id="mdl-title" class="muted"></span><span id="mdl-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="mdl-export" class="iconbtn" title="Export the model or a screenshot"><i data-icon="download"></i><span>Export</span></button>
|
||||
<button id="mdl-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<aside id="mdl-left" class="ims-left">
|
||||
<div class="mdl-kinds">
|
||||
<div id="mdl-kinds" class="seg">
|
||||
<button data-kind="model" class="on" title="Model configs (resource/model): skeleton + meshes + animations">Models</button>
|
||||
<button data-kind="msh" title="Meshes (.msh)">Meshes</button>
|
||||
<button data-kind="ani" title="Animations (.ani)">Anims</button>
|
||||
<button data-kind="skl" title="Skeletons (.skl)">Skeletons</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="mdl-search" type="search" placeholder="Search" spellcheck="false" /></div>
|
||||
<span id="mdl-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="mdl-list" class="mdl-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Double-click opens · right-click for more</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="mdl-left" data-edge="left" title="Drag to resize"></div>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<button id="mdl-t-tex" class="iconbtn toggle on" title="Textures"><i data-icon="image"></i><span>Textures</span></button>
|
||||
<button id="mdl-t-wire" class="iconbtn toggle" title="Wireframe"><i data-icon="grid"></i><span>Wire</span></button>
|
||||
<button id="mdl-t-bones" class="iconbtn toggle" title="Bones"><i data-icon="bone"></i><span>Bones</span></button>
|
||||
<button id="mdl-t-points" class="iconbtn toggle on" title="Control points (Type_Index)"><i data-icon="target"></i><span>Points</span></button>
|
||||
<button id="mdl-t-grid" class="iconbtn toggle on only" title="Floor grid"><i data-icon="floor"></i></button>
|
||||
<button id="mdl-t-box" class="iconbtn toggle only" title="Bounding box"><i data-icon="fit"></i></button>
|
||||
<button id="mdl-t-texanim" class="iconbtn toggle on only" title="Texture animations from the material scripts (scroll, rotate, wave, frames…)"><i data-icon="zap"></i></button>
|
||||
<div class="spacer"></div>
|
||||
<span id="mdl-info" class="muted ims-texinfo"></span>
|
||||
<button id="mdl-reset" class="iconbtn only" title="Reset the view (double-click the view)"><i data-icon="focus"></i></button>
|
||||
</div>
|
||||
<div class="mdl-stage">
|
||||
<canvas id="mdl-canvas" tabindex="0"></canvas>
|
||||
<div id="mdl-labels"></div>
|
||||
<div id="mdl-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
|
||||
</div>
|
||||
<div id="mdl-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="cube" class="big"></i>
|
||||
<h2>Model viewer</h2>
|
||||
<p>Pick the packs (<b>Choose packs…</b>, e.g. the client folder with map.iop and rs.iop), then double-click a model, mesh, animation or skeleton. Drag to turn, right-drag to pan, wheel to zoom.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mdl-timeline" class="mdl-timeline" hidden>
|
||||
<button id="mdl-play" class="iconbtn only" title="Play / pause (Space)"><i data-icon="play"></i></button>
|
||||
<div class="mdl-track">
|
||||
<div id="mdl-events" class="mdl-events"></div>
|
||||
<input id="mdl-time" type="range" min="0" max="1000" step="1" value="0" />
|
||||
</div>
|
||||
<span id="mdl-clock" class="mono"></span>
|
||||
<select id="mdl-speed" title="Speed">
|
||||
<option value="0.1">0.1×</option><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="1.5">1.5×</option><option value="2">2×</option>
|
||||
</select>
|
||||
<button id="mdl-loop" class="iconbtn toggle on only" title="Loop"><i data-icon="refresh"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="splitter" data-target="mdl-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="mdl-side" class="ims-side" hidden>
|
||||
<section class="ims-card">
|
||||
<h4>Scene <span id="mdl-scene-count" class="muted"></span></h4>
|
||||
<div id="mdl-scene" class="mdl-rows"></div>
|
||||
</section>
|
||||
<section id="mdl-mesh-card" class="ims-card" hidden>
|
||||
<h4>Mesh <span id="mdl-mesh-name" class="muted"></span></h4>
|
||||
<div id="mdl-mesh"></div>
|
||||
</section>
|
||||
<section id="mdl-ani-card" class="ims-card grow" hidden>
|
||||
<h4>Animation <span id="mdl-ani-name" class="muted"></span></h4>
|
||||
<div id="mdl-ani" class="mdl-ani"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,84 @@
|
||||
<section id="view-quests" class="view">
|
||||
<header class="bar top">
|
||||
<div class="zone-title"><h2>Quests</h2><span id="q-source" class="muted"></span></div>
|
||||
<div class="spacer"></div>
|
||||
<nav class="seg" id="q-subtabs">
|
||||
<button data-sub="quests" class="on"><i data-icon="scroll"></i>Quests</button>
|
||||
<button data-sub="rewards"><i data-icon="gem"></i>Rewards</button>
|
||||
<button data-sub="missions"><i data-icon="layout"></i>Missions</button>
|
||||
<button data-sub="checks"><i data-icon="check"></i>Checks</button>
|
||||
</nav>
|
||||
<select id="q-text" title="Text table (language) the texts come from and new texts go into"></select>
|
||||
<button id="q-export" class="iconbtn" title="Write the quest, reward and mission INIs as they are now into a folder, for the game server's config folder"><i data-icon="download"></i><span>Export for server</span></button>
|
||||
<button id="q-folder" class="iconbtn" title="Read the quests from another game folder"><i data-icon="folder"></i><span>Folder</span></button>
|
||||
<button id="q-reload" class="iconbtn" title="Read the packs again"><i data-icon="refresh"></i><span>Reload</span></button>
|
||||
</header>
|
||||
|
||||
<div class="sub on q-sub" data-sub="quests">
|
||||
<div class="q-body">
|
||||
<div class="q-list">
|
||||
<div class="bar nav">
|
||||
<div class="search"><i data-icon="search"></i><input id="q-search" type="search" placeholder="Title, main index, section, class" spellcheck="false" /></div>
|
||||
<select id="q-class" title="Quest class"></select>
|
||||
<select id="q-filter" title="Show">
|
||||
<option value="">All</option>
|
||||
<option value="event">Events (dates)</option>
|
||||
<option value="active">Events running now</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="repeat">Repeatable</option>
|
||||
<option value="problems">With problems</option>
|
||||
</select>
|
||||
<button id="q-new" class="iconbtn" title="New quest"><i data-icon="plus"></i><span>New</span></button>
|
||||
</div>
|
||||
<div id="q-table" class="vtable"></div>
|
||||
<div id="q-count" class="muted it-count"></div>
|
||||
</div>
|
||||
<div id="q-detail" class="q-detail"><div class="it-empty muted">Pick a quest.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub q-sub" data-sub="rewards">
|
||||
<div class="q-body two">
|
||||
<div class="q-list">
|
||||
<div class="bar nav">
|
||||
<div class="search"><i data-icon="search"></i><input id="qr-search" type="search" placeholder="RewardIndex, name, type" spellcheck="false" /></div>
|
||||
<select id="qr-type" title="Present type"></select>
|
||||
<label class="chip"><input id="qr-unused" type="checkbox" /><span>Unused only</span></label>
|
||||
<button id="qr-new" class="iconbtn" title="New reward"><i data-icon="plus"></i><span>New</span></button>
|
||||
</div>
|
||||
<div id="qr-table" class="vtable"></div>
|
||||
<div id="qr-count" class="muted it-count"></div>
|
||||
</div>
|
||||
<div id="qr-detail" class="q-detail"><div class="it-empty muted">Pick a reward.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub q-sub" data-sub="missions">
|
||||
<div class="q-body">
|
||||
<div class="q-list">
|
||||
<div class="bar nav">
|
||||
<nav class="seg" id="qm-kind">
|
||||
<button data-kind="daily" class="on">Daily</button>
|
||||
<button data-kind="weekly">Weekly</button>
|
||||
<button data-kind="monthly">Monthly</button>
|
||||
</nav>
|
||||
<div class="search"><i data-icon="search"></i><input id="qm-search" type="search" placeholder="Date, code, title" spellcheck="false" /></div>
|
||||
<button id="qm-new" class="iconbtn" title="New date (a copy of the selected one)"><i data-icon="plus"></i><span>New</span></button>
|
||||
</div>
|
||||
<div id="qm-table" class="vtable"></div>
|
||||
<div id="qm-count" class="muted it-count"></div>
|
||||
</div>
|
||||
<div id="qm-detail" class="q-detail"><div class="it-empty muted">Pick a date.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub q-sub" data-sub="checks">
|
||||
<div class="bar nav">
|
||||
<button id="qc-run" class="iconbtn"><i data-icon="refresh"></i><span>Check again</span></button>
|
||||
<select id="qc-sev"><option value="">Errors and warnings</option><option value="error">Errors only</option></select>
|
||||
<div class="spacer"></div>
|
||||
<span id="qc-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="qc-table" class="vtable"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,38 @@
|
||||
<section id="view-tables" class="view">
|
||||
<header class="bar top">
|
||||
<div class="zone-title"><h2>Tables</h2><span id="dt-source" class="muted"></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="dt-client" class="iconbtn" title="Read the client tables from another game folder (default: this profile's)"><i data-icon="box"></i><span>Game folder</span></button>
|
||||
<button id="dt-server" class="iconbtn" title="The game server's tables folder (ls_gamesvr\config\tables)"><i data-icon="server"></i><span>Server folder</span></button>
|
||||
<button id="dt-file" class="iconbtn" title="Open any .dat table file"><i data-icon="folder"></i><span>Open file</span></button>
|
||||
<button id="dt-maker" class="iconbtn" title="ioDataMaker folder (Config.xlsx + ExcelData): column descriptions and enum names"><i data-icon="gear"></i><span>ioDataMaker</span></button>
|
||||
<button id="dt-reload" class="iconbtn" title="List the tables again"><i data-icon="refresh"></i><span>Reload</span></button>
|
||||
</header>
|
||||
<div class="dt-body">
|
||||
<nav id="dt-list" class="dt-list"></nav>
|
||||
<div class="dt-main">
|
||||
<div class="bar nav" id="dt-tools">
|
||||
<div class="search"><i data-icon="search"></i><input id="dt-search" type="search" placeholder="Filter rows (any cell)" spellcheck="false" /></div>
|
||||
<select id="dt-enc" title="Code page of the text columns">
|
||||
<option value="euc-kr">Korean (CP949)</option>
|
||||
<option value="big5">Trad. Chinese (Big5)</option>
|
||||
<option value="windows-874">Thai (874)</option>
|
||||
<option value="gbk">Simpl. Chinese (GBK)</option>
|
||||
<option value="shift_jis">Japanese</option>
|
||||
<option value="windows-1252">Western (1252)</option>
|
||||
</select>
|
||||
<button id="dt-add" class="iconbtn" title="Add a row after the selection (Ctrl+Enter)"><i data-icon="plus"></i><span>Row</span></button>
|
||||
<button id="dt-dup" class="iconbtn" title="Duplicate the selected rows (Ctrl+D)"><i data-icon="copy"></i><span>Duplicate</span></button>
|
||||
<button id="dt-del" class="iconbtn danger" title="Delete the selected rows (Ctrl+Delete)"><i data-icon="trash"></i><span>Delete</span></button>
|
||||
<button id="dt-undo" class="iconbtn only" title="Undo (Ctrl+Z)"><i data-icon="undo"></i></button>
|
||||
<button id="dt-redo" class="iconbtn only" title="Redo (Ctrl+Y)"><i data-icon="redo"></i></button>
|
||||
<div class="spacer"></div>
|
||||
<button id="dt-import" class="iconbtn" title="Replace the rows with an .xlsx / .csv whose first row names the columns (an ioDataMaker sheet works too)"><i data-icon="upload"></i><span>Import</span></button>
|
||||
<button id="dt-export" class="iconbtn" title="Export as .xlsx (or .csv)"><i data-icon="download"></i><span>Export</span></button>
|
||||
<button id="dt-save" class="btn primary" disabled title="Write the table back (Ctrl+S)"><i data-icon="save"></i>Save</button>
|
||||
</div>
|
||||
<div id="dt-grid" class="dt-grid"><div class="it-empty muted">Pick a table.</div></div>
|
||||
<div id="dt-status" class="muted dt-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,53 @@
|
||||
<section id="view-tex" class="view">
|
||||
<header class="bar top">
|
||||
<button id="tex-open" class="iconbtn" title="Open a folder of textures (.png .jpg .tga .bmp .dds)"><i data-icon="folder"></i><span>Open folder…</span></button>
|
||||
<button id="tex-openfile" class="iconbtn" title="Open one picture (or drop it on the view)"><i data-icon="image"></i><span>Open file…</span></button>
|
||||
<button id="tex-convert" class="iconbtn" title="Every .png / .jpg / .tga / .bmp of the folder → .dds (the format in Save as .dds)"><i data-icon="swap"></i><span>Convert folder to .dds…</span></button>
|
||||
<div class="ims-title"><span id="tex-title" class="muted"></span><span id="tex-dirty" class="dot bad" title="Not saved yet" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="tex-saveas" class="btn" disabled><i data-icon="download"></i>Save as…</button>
|
||||
<button id="tex-save" class="btn primary" disabled title="<name>.dds next to the picture (Ctrl+S)"><i data-icon="save"></i>Save .dds</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<aside id="tex-left" class="ims-left">
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="tex-search" type="search" placeholder="Search" spellcheck="false" /></div>
|
||||
<span id="tex-count" class="muted"></span>
|
||||
<button id="tex-refresh" class="iconbtn only" title="Read the folder again"><i data-icon="refresh"></i></button>
|
||||
</div>
|
||||
<div id="tex-list" class="mdl-list tex-list"></div>
|
||||
<div id="tex-dir" class="ims-foot muted mdl-hint tex-dir"></div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="tex-left" data-edge="left" title="Drag to resize"></div>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<div id="tex-tools" class="seg"></div>
|
||||
<div class="spacer"></div>
|
||||
<span id="tex-zoom" class="muted mono"></span>
|
||||
<button id="tex-100" class="iconbtn" title="Actual size (100%)"><span>1:1</span></button>
|
||||
<button id="tex-fit" class="iconbtn only" title="Fit the picture (0)"><i data-icon="fit"></i></button>
|
||||
</div>
|
||||
<div class="mdl-stage tex-stage">
|
||||
<canvas id="tex-canvas" tabindex="0"></canvas>
|
||||
<div id="tex-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
|
||||
<div id="tex-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="brush" class="big"></i>
|
||||
<h2>Texture Studio</h2>
|
||||
<p><b>Open folder…</b> and click a picture. Select what to change — <b>Magic wand</b> / <b>Colour range</b> (click a colour), <b>Lasso</b>, <b>Rectangle</b>; Shift adds, Alt takes away — then <b>Recolour</b> it and <b>Apply</b>.</p>
|
||||
<p class="muted"><b>Save .dds</b> writes the game's format (DXT1 / DXT5 with mip maps, like its own textures); <b>Convert folder to .dds…</b> does every .png of the folder at once. Wheel: zoom, middle / right drag or Space: move.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="splitter" data-target="tex-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="tex-side" class="ims-side ans-side">
|
||||
<section class="ims-card"><h4>Selection</h4><div id="tex-sel"></div></section>
|
||||
<section class="ims-card"><h4>Recolour</h4><div id="tex-rc"></div></section>
|
||||
<section class="ims-card"><h4>Save as .dds</h4><div id="tex-exp"></div></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,80 @@
|
||||
<section id="view-ui" class="view">
|
||||
<header class="bar top">
|
||||
<label class="pill" title="Pack the XML (and textures) come from — open it in IOP Manager first">
|
||||
<i data-icon="box"></i>
|
||||
<select id="ims-session" disabled></select>
|
||||
</label>
|
||||
<input id="ims-entry" type="text" value="xml/uiimageset.xml" spellcheck="false" title="Entry inside the pack" />
|
||||
<button id="ims-load" class="iconbtn" disabled><i data-icon="refresh"></i><span>Load</span></button>
|
||||
<button id="ims-open-file" class="iconbtn" title="Open an imageset XML from disk"><i data-icon="folder"></i><span>Open file…</span></button>
|
||||
<div class="ims-title"><span id="ims-title" class="muted"></span><span id="ims-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
|
||||
<button id="ims-texsrc" class="iconbtn" title="Where the textures come from"><i data-icon="image"></i><span id="ims-texsrc-label">Textures</span></button>
|
||||
<div class="spacer"></div>
|
||||
<button id="ims-undo" class="iconbtn only" title="Undo (Ctrl+Z)"><i data-icon="undo"></i></button>
|
||||
<button id="ims-redo" class="iconbtn only" title="Redo (Ctrl+Y)"><i data-icon="redo"></i></button>
|
||||
<button id="ims-save" class="btn primary"><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<aside id="ims-left" class="ims-left">
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="ims-search" type="search" placeholder="Search imagesets" spellcheck="false" /></div>
|
||||
<span id="ims-set-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="ims-sets" class="ims-list"></div>
|
||||
<div class="ims-foot">
|
||||
<button id="ims-add-set" class="iconbtn" title="New imageset"><i data-icon="plus"></i><span>Imageset</span></button>
|
||||
<button id="ims-del-set" class="iconbtn only danger" title="Delete this imageset"><i data-icon="trash"></i></button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="ims-left" data-edge="left" title="Drag to resize"></div>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<div class="seg">
|
||||
<button id="ims-zoom-out" title="Zoom out"><i data-icon="minus"></i></button>
|
||||
<button id="ims-zoom" title="100 %">100%</button>
|
||||
<button id="ims-zoom-in" title="Zoom in"><i data-icon="plus"></i></button>
|
||||
</div>
|
||||
<button id="ims-fit" class="iconbtn" title="Fit the texture"><i data-icon="fit"></i><span>Fit</span></button>
|
||||
<button id="ims-showall" class="iconbtn toggle on" title="Show every box"><i data-icon="grid"></i><span>All boxes</span></button>
|
||||
<button id="ims-drawmode" class="iconbtn toggle" title="Drag on the texture to add an image"><i data-icon="draw"></i><span>Draw</span></button>
|
||||
<div class="spacer"></div>
|
||||
<span id="ims-hover" class="mono"></span>
|
||||
<span id="ims-texinfo" class="muted ims-texinfo"></span>
|
||||
</div>
|
||||
<div class="ims-stage"><canvas id="ims-canvas" tabindex="0"></canvas></div>
|
||||
<div id="ims-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="image" class="big"></i>
|
||||
<h2>UI Image Set editor</h2>
|
||||
<p>Open <b>rs.iop</b> in IOP Manager, then press <b>Load</b> to edit <code>xml/uiimageset.xml</code>. Textures are read from the packs next to it (map.iop, map2.iop, sakura.iop, …).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ims-help muted">Drag a box to move it, its handles to resize · Alt/right-drag or drag empty space to pan · wheel to zoom · arrows nudge (Shift ×10) · Delete removes</div>
|
||||
</div>
|
||||
|
||||
<div class="splitter" data-target="ims-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="ims-side" class="ims-side" hidden>
|
||||
<section class="ims-card">
|
||||
<h4>Imageset</h4>
|
||||
<div id="ims-setform" class="ims-form"></div>
|
||||
</section>
|
||||
<section class="ims-card grow">
|
||||
<h4>Images <span id="ims-img-count" class="muted"></span></h4>
|
||||
<div class="search"><i data-icon="search"></i><input id="ims-img-search" type="search" placeholder="Search images" spellcheck="false" /></div>
|
||||
<div id="ims-images" class="ims-list small"></div>
|
||||
<div class="ims-foot">
|
||||
<button id="ims-add-img" class="iconbtn" title="New image (or use Draw)"><i data-icon="plus"></i><span>Image</span></button>
|
||||
<button id="ims-dup-img" class="iconbtn only" title="Duplicate"><i data-icon="copy"></i></button>
|
||||
<button id="ims-del-img" class="iconbtn only danger" title="Delete (Del)"><i data-icon="trash"></i></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="ims-imgbox" class="ims-card">
|
||||
<h4>Selected image</h4>
|
||||
<div id="ims-imgform" class="ims-form"></div>
|
||||
<div class="ims-cropwrap"><canvas id="ims-crop"></canvas></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,62 @@
|
||||
<section id="view-wnd" class="view">
|
||||
<header class="bar top">
|
||||
<button id="wnd-src" class="iconbtn" title="Where the UI files are read from"><i data-icon="box"></i><span id="wnd-src-label">Choose packs…</span></button>
|
||||
<button id="wnd-open-file" class="iconbtn" title="Open a window .xml from disk"><i data-icon="folder"></i><span>Open file…</span></button>
|
||||
<div class="ims-title"><span id="wnd-title" class="muted"></span><span id="wnd-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
|
||||
<div class="spacer"></div>
|
||||
<button id="wnd-undo" class="iconbtn only" title="Undo (Ctrl+Z)" disabled><i data-icon="undo"></i></button>
|
||||
<button id="wnd-redo" class="iconbtn only" title="Redo (Ctrl+Y)" disabled><i data-icon="redo"></i></button>
|
||||
<button id="wnd-save" class="btn primary"><i data-icon="save"></i>Save</button>
|
||||
</header>
|
||||
<div class="split">
|
||||
<aside id="wnd-left" class="ims-left">
|
||||
<div class="ims-head">
|
||||
<div class="search"><i data-icon="search"></i><input id="wnd-search" type="search" placeholder="Search window files" spellcheck="false" /></div>
|
||||
<span id="wnd-count" class="muted"></span>
|
||||
</div>
|
||||
<div id="wnd-list" class="mdl-list"></div>
|
||||
<div class="ims-foot muted mdl-hint">Double-click a file to open it</div>
|
||||
</aside>
|
||||
<div class="splitter" data-target="wnd-left" data-edge="left" title="Drag to resize"></div>
|
||||
<div class="main-col">
|
||||
<div class="bar nav">
|
||||
<div class="seg"><button id="wnd-zoom" title="100 %">100%</button></div>
|
||||
<button id="wnd-fit" class="iconbtn" title="Fit the window"><i data-icon="fit"></i><span>Fit</span></button>
|
||||
<label class="pill" title="Game screen size (for aligned windows)"><i data-icon="grid"></i>
|
||||
<select id="wnd-screen"><option value="800x600">800×600</option><option value="1024x768" selected>1024×768</option><option value="1280x720">1280×720</option><option value="1920x1080">1920×1080</option></select>
|
||||
</label>
|
||||
<label class="pill" title="Button state to show"><i data-icon="target"></i>
|
||||
<select id="wnd-state"><option>Normal</option><option>Over</option><option>Push</option><option>Disable</option></select>
|
||||
</label>
|
||||
<button id="wnd-t-preview" class="iconbtn toggle" title="Preview: the window as the game shows it (no outlines, no hidden windows)"><i data-icon="play"></i><span>Preview</span></button>
|
||||
<button id="wnd-t-img" class="iconbtn toggle on only" title="Images and frames"><i data-icon="image"></i></button>
|
||||
<button id="wnd-t-text" class="iconbtn toggle on only" title="Texts"><i data-icon="note"></i></button>
|
||||
<button id="wnd-t-bounds" class="iconbtn toggle on" title="Window outlines"><i data-icon="draw"></i><span>Bounds</span></button>
|
||||
<button id="wnd-t-hidden" class="iconbtn toggle on" title="Windows that start hidden (IWS_START_HIDE)"><i data-icon="copy"></i><span>Hidden</span></button>
|
||||
<div class="spacer"></div>
|
||||
<span id="wnd-textinfo" class="muted ims-texinfo"></span>
|
||||
<span id="wnd-hover" class="mono ims-texinfo"></span>
|
||||
</div>
|
||||
<div class="ims-stage"><canvas id="wnd-canvas" tabindex="0"></canvas></div>
|
||||
<div id="wnd-empty" class="empty">
|
||||
<div class="empty-card">
|
||||
<i data-icon="layout" class="big"></i>
|
||||
<h2>UI window editor</h2>
|
||||
<p>Pick the packs, then double-click a window file (xml/…wnd.xml). Click a window to select it, drag to move, handles to resize; every element and attribute can be edited on the right.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ims-help muted">Click selects · drag moves · handles resize · arrows nudge (Shift ×10) · Alt/right-drag pans · wheel zooms · Del removes a child window</div>
|
||||
</div>
|
||||
<div class="splitter" data-target="wnd-side" data-edge="right" title="Drag to resize"></div>
|
||||
<aside id="wnd-side" class="ims-side map-side" hidden>
|
||||
<section class="ims-card">
|
||||
<h4>Windows <span id="wnd-count-win" class="muted"></span></h4>
|
||||
<div id="wnd-tree" class="wnd-tree"></div>
|
||||
</section>
|
||||
<section id="wnd-props-card" class="ims-card grow" hidden>
|
||||
<h4><span id="wnd-props-name"></span></h4>
|
||||
<div id="wnd-props" class="wnd-props"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
+121
@@ -0,0 +1,121 @@
|
||||
// LosaDev Tools production server: serves the Vite build in dist/ with no extra dependencies.
|
||||
// Settings come from .env next to this file (or the real environment, which wins):
|
||||
// PORT port to listen on (default 4700)
|
||||
// HOST address to bind (default 0.0.0.0; use 127.0.0.1 behind a reverse proxy)
|
||||
// The tools read game files in the visitor's browser (File System Access API), which needs HTTPS
|
||||
// on a real domain: put this behind the panel's reverse proxy with a certificate.
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadEnv(file) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1);
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
loadEnv(path.join(here, ".env"));
|
||||
|
||||
const PORT = Number.parseInt(process.env.PORT, 10) || 4700;
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const ROOT = path.join(here, "dist");
|
||||
|
||||
if (!fs.existsSync(path.join(ROOT, "index.html"))) {
|
||||
console.error("dist/index.html not found. Run `npm run build` first (or `npm run rebuild`).");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".wasm": "application/wasm",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".ini": "text/plain; charset=utf-8",
|
||||
};
|
||||
const COMPRESS = /^(text\/|application\/(json|javascript|xml|wasm)|image\/svg)/;
|
||||
|
||||
function resolveFile(urlPath) {
|
||||
let rel;
|
||||
try {
|
||||
rel = decodeURIComponent(urlPath.split("?")[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const full = path.normalize(path.join(ROOT, rel));
|
||||
if (full !== ROOT && !full.startsWith(ROOT + path.sep)) return null; // no ../ out of dist
|
||||
try {
|
||||
const st = fs.statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
const index = path.join(full, "index.html");
|
||||
return fs.existsSync(index) ? index : null;
|
||||
}
|
||||
return full;
|
||||
} catch {
|
||||
// Routes without a file extension fall back to the app (it routes by #hash, this is a safety net).
|
||||
return path.extname(rel) ? null : path.join(ROOT, "index.html");
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405, { Allow: "GET, HEAD" });
|
||||
return res.end();
|
||||
}
|
||||
const file = resolveFile(req.url || "/");
|
||||
if (!file) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
return res.end("Not found");
|
||||
}
|
||||
const type = TYPES[path.extname(file).toLowerCase()] || "application/octet-stream";
|
||||
// Vite puts content-hashed files in /assets/: cache them for a year. Everything else
|
||||
// (index.html, legacy/ files that use a ?v= query) is revalidated on every visit.
|
||||
const hashed = req.url.startsWith("/assets/");
|
||||
const headers = { "Content-Type": type, "Cache-Control": hashed ? "public, max-age=31536000, immutable" : "no-cache" };
|
||||
const gzip = COMPRESS.test(type) && /\bgzip\b/.test(req.headers["accept-encoding"] || "");
|
||||
if (gzip) {
|
||||
headers["Content-Encoding"] = "gzip";
|
||||
headers.Vary = "Accept-Encoding";
|
||||
} else {
|
||||
headers["Content-Length"] = fs.statSync(file).size;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
if (req.method === "HEAD") return res.end();
|
||||
const stream = fs.createReadStream(file);
|
||||
stream.on("error", () => res.destroy());
|
||||
(gzip ? stream.pipe(zlib.createGzip()) : stream).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => console.log(`LosaDev Tools on http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${PORT}`));
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
// UI side of the worker bridge: api.call(channel, ...args) like the desktop's Electron IPC.
|
||||
// Files the worker wrote during a call (a "save as" target, an export) come back with the answer
|
||||
// and are handed to onWritten (the page turns them into downloads).
|
||||
let worker = null;
|
||||
const pending = new Map();
|
||||
const early = new Map(); // id → { done, total } reported before the busy overlay showed
|
||||
let nextId = 1;
|
||||
let writtenHandler = null;
|
||||
let persistHandler = null;
|
||||
const taskHandlers = new Set();
|
||||
|
||||
export function onWritten(fn) {
|
||||
writtenHandler = fn;
|
||||
}
|
||||
|
||||
/** Files changed under the worker's /userdata (to keep in IndexedDB). */
|
||||
export function onPersist(fn) {
|
||||
persistHandler = fn;
|
||||
}
|
||||
|
||||
/** Progress events of long jobs ({ taskId, type, done, total, ... }), like the desktop "task" event. */
|
||||
export function onTask(fn) {
|
||||
taskHandlers.add(fn);
|
||||
}
|
||||
|
||||
function onMessage({ data }) {
|
||||
if (data.task) {
|
||||
for (const fn of taskHandlers) fn(data.task);
|
||||
return;
|
||||
}
|
||||
if (data.progress) {
|
||||
const { id, done, total } = data.progress;
|
||||
const b = busyCalls.get(id);
|
||||
if (b) Object.assign(b, { done, total });
|
||||
else if (pending.has(id)) early.set(id, { done, total }); // before the overlay showed
|
||||
return;
|
||||
}
|
||||
const p = pending.get(data.id);
|
||||
if (!p) return;
|
||||
pending.delete(data.id);
|
||||
if (data.persisted && data.persisted.length && persistHandler) persistHandler(data.persisted);
|
||||
if (data.written && data.written.length && writtenHandler) writtenHandler(data.written);
|
||||
if (data.ok) p.resolve(data.value);
|
||||
else p.reject(Object.assign(new Error(data.error), { code: data.code, workerStack: data.stack }));
|
||||
}
|
||||
|
||||
function startWorker() {
|
||||
worker = new Worker(new URL("./worker/worker.js", import.meta.url), { type: "module" });
|
||||
worker.onmessage = onMessage;
|
||||
worker.onerror = (e) => {
|
||||
for (const p of pending.values()) p.reject(new Error(e.message || "The tools worker stopped"));
|
||||
pending.clear();
|
||||
};
|
||||
}
|
||||
startWorker();
|
||||
|
||||
const restartHandlers = new Set();
|
||||
/** fn() after restartWorker(): mount the game folder again, send keys, restore settings. */
|
||||
export function onRestart(fn) {
|
||||
restartHandlers.add(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops whatever the worker is doing right now (a synchronous step can't be interrupted any other
|
||||
* way) and starts a fresh one. Every running call fails with code "cancelled"; the page then
|
||||
* mounts the game folder again (onRestart).
|
||||
*/
|
||||
export async function restartWorker() {
|
||||
worker.terminate();
|
||||
const err = Object.assign(new Error("Cancelled"), { code: "cancelled" });
|
||||
for (const p of pending.values()) p.reject(err);
|
||||
pending.clear();
|
||||
early.clear();
|
||||
startWorker();
|
||||
for (const fn of restartHandlers) await fn();
|
||||
}
|
||||
|
||||
// ---------- busy state (owner: show clearly that something loads, and block clicks meanwhile) ----------
|
||||
// A call still running after BUSY_AFTER ms turns the page busy until every such call is done.
|
||||
// Quick calls (thumbnails, small reads) never show it; long jobs with their own progress bar
|
||||
// (task channels) and bookkeeping channels don't count.
|
||||
const BUSY_AFTER = 300;
|
||||
const QUIET = /^(task:|vfs:|keys:|userdata:|debug:|dat:close|game:kind)/;
|
||||
const LABELS = [
|
||||
[/^(game:|packs:)/, "Reading the game folder…"],
|
||||
[/^ims:/, "Reading UI image sets…"],
|
||||
[/^(xml:|wnd:)/, "Reading UI windows…"],
|
||||
[/^(mdl:list|mdl:names)/, "Listing models…"],
|
||||
[/^mdl:/, "Loading the model…"],
|
||||
[/^map:/, "Reading maps…"],
|
||||
[/^(fx:|fxlab:)/, "Reading effects…"],
|
||||
[/^tex:/, "Listing textures…"],
|
||||
[/^anim:/, "Reading animations…"],
|
||||
[/^items:/, "Reading items…"],
|
||||
[/^cos:/, "Reading costumes…"],
|
||||
[/^hero:/, "Reading heroes…"],
|
||||
[/^q:/, "Reading quests…"],
|
||||
[/^dat:/, "Reading tables…"],
|
||||
[/^iopw:verify/, "Verifying the pack…"],
|
||||
[/^iopw:extract/, "Extracting…"],
|
||||
[/^iopw:build/, "Writing the pack…"],
|
||||
[/^iopw:(add|replace)/, "Adding files…"],
|
||||
[/^iopw:/, "Reading the pack…"],
|
||||
];
|
||||
const busyCalls = new Map(); // id → { label, channel, start, done, total }
|
||||
let busyHandler = null;
|
||||
let busyShown = false;
|
||||
let hideTimer = 0;
|
||||
let ticker = 0;
|
||||
|
||||
// How long each channel took last time: the percent of calls that don't report steps is an
|
||||
// estimate from that (capped at 95 % until the answer arrives).
|
||||
const DUR_KEY = "lsdh-tools-durations-v2"; // v1 also counted cached answers
|
||||
let durations = {};
|
||||
try {
|
||||
durations = JSON.parse(localStorage.getItem(DUR_KEY) || "{}");
|
||||
} catch {
|
||||
durations = {};
|
||||
}
|
||||
function rememberDuration(channel, ms) {
|
||||
durations[channel] = Math.round(durations[channel] ? durations[channel] * 0.5 + ms * 0.5 : ms);
|
||||
try {
|
||||
localStorage.setItem(DUR_KEY, JSON.stringify(durations));
|
||||
} catch {
|
||||
// estimate for this visit only
|
||||
}
|
||||
}
|
||||
|
||||
let busyNote = "";
|
||||
/** A line shown before the call's own label (e.g. "Full load · step 3 of 12"). */
|
||||
export function setBusyNote(note) {
|
||||
busyNote = note || "";
|
||||
}
|
||||
|
||||
/** fn(busy: boolean, label: string, percent: number | null, seconds: number) */
|
||||
export function onBusy(fn) {
|
||||
busyHandler = fn;
|
||||
}
|
||||
|
||||
function report() {
|
||||
const list = [...busyCalls.values()];
|
||||
const b = list[list.length - 1];
|
||||
if (!b) return;
|
||||
const elapsed = performance.now() - b.start;
|
||||
let pct = null;
|
||||
if (b.total) pct = Math.min(99, Math.floor((100 * b.done) / b.total));
|
||||
else if (durations[b.channel] > 0) pct = Math.min(95, Math.floor((100 * elapsed) / durations[b.channel]));
|
||||
busyHandler?.(true, busyNote ? `${busyNote} — ${b.label}` : b.label, pct, Math.floor(elapsed / 1000));
|
||||
}
|
||||
|
||||
function syncBusy() {
|
||||
const busy = busyCalls.size > 0;
|
||||
if (busy) {
|
||||
clearTimeout(hideTimer);
|
||||
busyShown = true;
|
||||
report();
|
||||
if (!ticker) ticker = setInterval(report, 200);
|
||||
} else if (busyShown) {
|
||||
clearInterval(ticker);
|
||||
ticker = 0;
|
||||
// a short gap between two calls of one job keeps the overlay up
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => {
|
||||
busyShown = false;
|
||||
busyHandler?.(false, "", null, 0);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
export function call(channel, ...args) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = 0;
|
||||
const start = performance.now();
|
||||
const quiet = QUIET.test(channel);
|
||||
if (!quiet) {
|
||||
timer = setTimeout(() => {
|
||||
const label = (LABELS.find(([re]) => re.test(channel)) || [null, "Loading…"])[1];
|
||||
busyCalls.set(id, { label, channel, start, done: 0, total: 0, ...(early.get(id) || {}) });
|
||||
early.delete(id);
|
||||
syncBusy();
|
||||
}, BUSY_AFTER);
|
||||
}
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
early.delete(id);
|
||||
// only real work counts (a cached answer would make the next estimate jump to 95 %)
|
||||
if (!quiet && performance.now() - start > BUSY_AFTER) rememberDuration(channel, performance.now() - start);
|
||||
if (busyCalls.delete(id)) syncBusy();
|
||||
};
|
||||
pending.set(id, {
|
||||
resolve: (v) => (done(), resolve(v)),
|
||||
reject: (e) => (done(), reject(e)),
|
||||
});
|
||||
worker.postMessage({ id, channel, args });
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire-and-forget message that is still ordered before every later call (used by pathOf). */
|
||||
export function post(channel, ...args) {
|
||||
call(channel, ...args).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* Encode / Decode page: same cards and 8px buttons as the IOP Manager page, full width, one column on phones. */
|
||||
.cipher-area[hidden] { display: none; }
|
||||
.cipher-area { flex: 1; min-width: 0; height: 100%; overflow: auto; }
|
||||
.cipher {
|
||||
--surface: #111733;
|
||||
--stage: #0a0f1e;
|
||||
--line: rgba(212, 167, 44, 0.24);
|
||||
--muted: #a3a9bd;
|
||||
padding: 0 16px 24px;
|
||||
font: 13px/1.45 var(--body);
|
||||
color: var(--text);
|
||||
}
|
||||
.cipher-head { margin-bottom: 14px; }
|
||||
.cipher-head h1 { margin: 16px 0 2px; font-size: 24px; }
|
||||
.cipher-head p, .cipher-help { margin: 0; color: var(--muted); }
|
||||
|
||||
/* Layout by list width (cards can't query themselves):
|
||||
< 600 one column, fields stacked (input / actions / output)
|
||||
600 - 893 one column, fields side by side (input | actions | output)
|
||||
894 - 1347 two columns stacked; the odd last card spans the row, side by side
|
||||
>= 1348 three columns stacked
|
||||
Stacked cards in one row share their rows (subgrid), so labels, buttons and fields line up. */
|
||||
.cipher-list {
|
||||
container: cipher-list / inline-size;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr));
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.cipher-card {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "head" "a" "go" "b" "err";
|
||||
row-gap: 12px;
|
||||
align-content: start;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.cipher-card-head { grid-area: head; }
|
||||
.cipher-card h2 { margin: 0 0 4px; font-size: 16px; }
|
||||
.cipher-a { grid-area: a; min-width: 0; }
|
||||
.cipher-b { grid-area: b; min-width: 0; }
|
||||
.cipher-go { grid-area: go; display: flex; justify-content: center; gap: 8px; }
|
||||
.cipher-error { grid-area: err; min-height: 1.2em; margin: 0; color: #ff8d8d; }
|
||||
|
||||
.cipher-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.cipher-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 26px; color: var(--muted); }
|
||||
.cipher-field-head label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cipher-row { display: flex; align-items: center; gap: 6px; }
|
||||
.cipher-row input { flex: 1; min-width: 0; }
|
||||
.cipher-row input.port { flex: 0 0 84px; }
|
||||
.cipher-colon { color: var(--muted); font: 600 14px ui-monospace, Consolas, monospace; }
|
||||
.cipher-field input, .cipher-field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--stage);
|
||||
color: var(--text);
|
||||
font: 13px/1.4 ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.cipher-field textarea { height: auto; min-height: 36px; resize: vertical; }
|
||||
.cipher-field input:focus, .cipher-field textarea:focus { outline: none; border-color: #d4a72c; }
|
||||
|
||||
.cipher-btn {
|
||||
height: 34px;
|
||||
min-width: 112px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: 600 13px var(--body);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cipher-btn:hover { border-color: rgba(212, 167, 44, 0.55); background: rgba(212, 167, 44, 0.08); }
|
||||
.cipher-btn:focus-visible, .cipher-copy:focus-visible { outline: 2px solid var(--gold-hi); outline-offset: 2px; }
|
||||
.cipher-btn.primary { border-color: transparent; background: #d4a72c; color: #17140a; }
|
||||
.cipher-btn.primary:hover { background: #e2b93f; }
|
||||
.cipher-btn .arrow-v:not(:empty), .cipher-btn .arrow-h:not(:empty) { margin: 0 6px; }
|
||||
.cipher-btn .arrow-h { display: none; }
|
||||
|
||||
.cipher-copy {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 600 12px var(--body);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.cipher-copy:hover { color: var(--text); border-color: rgba(212, 167, 44, 0.55); }
|
||||
|
||||
/* after the base rules on purpose: a container query adds no specificity */
|
||||
@container cipher-list (min-width: 894px) {
|
||||
.cipher-card { grid-row: span 5; grid-template-rows: subgrid; }
|
||||
}
|
||||
@container cipher-list (min-width: 600px) and (max-width: 893px) {
|
||||
.cipher-card {
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
grid-template-areas: "head head head" "a go b" "err err err";
|
||||
column-gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
.cipher-go { flex-direction: column; }
|
||||
.cipher-btn .arrow-v { display: none; }
|
||||
.cipher-btn .arrow-h { display: inline; }
|
||||
}
|
||||
@container cipher-list (min-width: 894px) and (max-width: 1347px) {
|
||||
.cipher-card:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: auto;
|
||||
grid-template-rows: none;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
grid-template-areas: "head head head" "a go b" "err err err";
|
||||
column-gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
.cipher-card:last-child:nth-child(odd) .cipher-go { flex-direction: column; }
|
||||
.cipher-card:last-child:nth-child(odd) .cipher-btn .arrow-v { display: none; }
|
||||
.cipher-card:last-child:nth-child(odd) .cipher-btn .arrow-h { display: inline; }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Encode / Decode page: Server ID, pack password, DB agent password. Runs in the page, nothing is sent anywhere.
|
||||
import * as C from "./cipher.js";
|
||||
import "./cipher-page.css";
|
||||
|
||||
function el(tag, attrs = {}, ...kids) {
|
||||
const n = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") n.className = v;
|
||||
else if (k === "text") n.textContent = v;
|
||||
else n.setAttribute(k, v);
|
||||
}
|
||||
n.append(...kids.filter(Boolean));
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Two fields that convert into each other: editing one fills the other. */
|
||||
function pair({ title, help, left, right, encode, decode }) {
|
||||
const error = el("p", { class: "cipher-error" });
|
||||
const a = left.make(), b = right.make();
|
||||
const run = (fn, target) => {
|
||||
try {
|
||||
error.textContent = "";
|
||||
target(fn());
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
}
|
||||
};
|
||||
const toRight = () => run(() => encode(a.get()), b.set);
|
||||
const toLeft = () => run(() => decode(b.get()), a.set);
|
||||
// The arrow follows the layout: down/up when the fields are stacked, right/left when side by side.
|
||||
const action = (text, cls, before, after, fn) => {
|
||||
const b = el("button", { class: `cipher-btn${cls}`, type: "button" },
|
||||
el("span", { class: "arrow-v", text: before }), el("span", { class: "arrow-h", text: before ? "←" : "" }),
|
||||
document.createTextNode(text),
|
||||
el("span", { class: "arrow-v", text: after }), el("span", { class: "arrow-h", text: after ? "→" : "" }));
|
||||
b.addEventListener("click", fn);
|
||||
return b;
|
||||
};
|
||||
for (const f of [a, b]) {
|
||||
f.copy.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(f.text());
|
||||
f.copy.textContent = "Copied";
|
||||
setTimeout(() => (f.copy.textContent = "Copy"), 1200);
|
||||
} catch {
|
||||
error.textContent = "The browser did not allow copying.";
|
||||
}
|
||||
});
|
||||
}
|
||||
return el(
|
||||
"section",
|
||||
{ class: "cipher-card" },
|
||||
el("div", { class: "cipher-card-head" }, el("h2", { text: title }), el("p", { class: "cipher-help", text: help })),
|
||||
el("div", { class: "cipher-a" }, a.node),
|
||||
el("div", { class: "cipher-go" }, action("Encrypt", " primary", "", "↓", toRight), action("Decrypt", "", "↑", "", toLeft)),
|
||||
el("div", { class: "cipher-b" }, b.node),
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
let uid = 0;
|
||||
/** Label row (label + small Copy button) above the input(s). */
|
||||
function fieldShell(label, forId, ...inputs) {
|
||||
const copy = el("button", { class: "cipher-copy", type: "button", text: "Copy" });
|
||||
const node = el("div", { class: "cipher-field" },
|
||||
el("div", { class: "cipher-field-head" }, el("label", { for: forId, text: label }), copy),
|
||||
...inputs);
|
||||
return { node, copy };
|
||||
}
|
||||
|
||||
const field = (label, placeholder, multi) => () => {
|
||||
const id = `cipher-${++uid}`;
|
||||
const input = el(multi ? "textarea" : "input", { id, placeholder, spellcheck: "false", autocomplete: "off", ...(multi ? { rows: "2" } : {}) });
|
||||
const { node, copy } = fieldShell(label, id, input);
|
||||
return {
|
||||
node,
|
||||
copy,
|
||||
get: () => input.value,
|
||||
set: (v) => { input.value = v; },
|
||||
text: () => input.value,
|
||||
};
|
||||
};
|
||||
|
||||
function serverLeft() {
|
||||
const id = `cipher-${++uid}`;
|
||||
const ip = el("input", { id, placeholder: "192.168.0.10", spellcheck: "false", autocomplete: "off", "aria-label": "IP address" });
|
||||
const port = el("input", { class: "port", placeholder: "14009", inputmode: "numeric", autocomplete: "off", "aria-label": "Port" });
|
||||
const { node, copy } = fieldShell("IP address and port", id, el("div", { class: "cipher-row" }, ip, el("span", { class: "cipher-colon", text: ":" }), port));
|
||||
return {
|
||||
node,
|
||||
copy,
|
||||
get: () => ({ ip: ip.value, port: port.value }),
|
||||
set: (v) => { ip.value = v.ip; port.value = v.port; },
|
||||
text: () => `${ip.value}:${port.value}`,
|
||||
};
|
||||
}
|
||||
|
||||
let root = null;
|
||||
|
||||
export function showCipher(parent) {
|
||||
if (!root) {
|
||||
root = el(
|
||||
"div",
|
||||
{ class: "cipher-area" },
|
||||
el("div", { class: "cipher" },
|
||||
el("header", { class: "cipher-head" }, el("h1", { text: "Encode / Decode" }), el("p", { text: "Server IDs and passwords the game stores encoded. Runs in this tab, nothing is uploaded." })),
|
||||
el("div", { class: "cipher-list" },
|
||||
pair({
|
||||
title: "Server ID",
|
||||
help: "The last field of the client's launch line: the game server's address and port packed into one 64-bit number.",
|
||||
left: { make: serverLeft },
|
||||
right: { make: field("Server ID", "90232984699007") },
|
||||
encode: (v) => C.encodeServerId(v.ip, v.port),
|
||||
decode: (v) => C.decodeServerId(v),
|
||||
}),
|
||||
pair({
|
||||
title: "IOP password",
|
||||
help: "The szEncPassWord values in io3DEngine\\ioLocalManagerParent.cpp. Twenty signed values; short passwords are filled up with zero bytes.",
|
||||
left: { make: field("Password", "up to 20 characters") },
|
||||
right: { make: field("szEncPassWord", "-82,112,45,50,...", true) },
|
||||
encode: (v) => C.encodePackPassword(v).join(","),
|
||||
decode: (v) => C.decodePackPassword(v),
|
||||
}),
|
||||
pair({
|
||||
title: "DB agent password",
|
||||
help: "The PW value in ls_dbagent.ini. Encrypt gives the same result as ls_dbagent.exe -x.",
|
||||
left: { make: field("Password", "SQL login password") },
|
||||
right: { make: field("PW value", "hex text") },
|
||||
encode: (v) => C.encodeDbPassword(v),
|
||||
decode: (v) => C.decodeDbPassword(v),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
parent.append(root);
|
||||
}
|
||||
root.hidden = false;
|
||||
}
|
||||
|
||||
export function hideCipher() {
|
||||
if (root) root.hidden = true;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// The small encodings of the game, written from the sources (same in the 2014, 2019 and 2021 releases).
|
||||
|
||||
// io3DEngine ioLocalManagerParent::EncryptDecryptData, password key.
|
||||
const PACK_KEY = [255, 1, 2, 9, 89, 32, 123, 39, 34, 211, 222, 244, 100, 129, 23, 1, 4, 3, 29, 30, 1, 4, 5, 7, 8, 233, 89, 1, 98, 67];
|
||||
// ls_dbagent cQueryManager::EncryptDecryptData.
|
||||
const DB_KEY = [1, 3, 5, 7, 8, 9, 3, 45, 255, 39, 12, 34, 56, 78, 12, 43, 123, 124, 124, 152, 176, 129, 109, 123, 123, 213, 212, 218, 219, 234];
|
||||
const KEY_SIZE = 30;
|
||||
export const PACK_PASSWORD_SIZE = 20; // MAX_PASSWORD: the C array always has 20 values
|
||||
|
||||
const xorBytes = (bytes, key) => {
|
||||
const n = bytes.length;
|
||||
return bytes.map((b, i) => (b & 0xff) ^ key[i % KEY_SIZE] ^ key[(n - i) % KEY_SIZE]);
|
||||
};
|
||||
const toBytes = (text) => Array.from(text, (c) => {
|
||||
const code = c.charCodeAt(0);
|
||||
if (code > 255) throw new Error("Use Latin characters only.");
|
||||
return code;
|
||||
});
|
||||
const toText = (bytes) => String.fromCharCode(...bytes);
|
||||
|
||||
/** Server ID: the last field of the launch line. IPv4 bytes in the low half, port in the high half. */
|
||||
export function encodeServerId(ip, port) {
|
||||
const parts = String(ip).trim().split(".");
|
||||
if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p) && +p <= 255)) throw new Error("Enter an IPv4 address such as 192.168.0.10.");
|
||||
const p = Number(port);
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("Enter a port from 1 to 65535.");
|
||||
const [a, b, c, d] = parts.map(BigInt);
|
||||
return (BigInt(p) << 32n | d << 24n | c << 16n | b << 8n | a).toString();
|
||||
}
|
||||
|
||||
export function decodeServerId(value) {
|
||||
const text = String(value).trim();
|
||||
if (!/^\d+$/.test(text)) throw new Error("Enter the Server ID as a number.");
|
||||
const v = BigInt(text);
|
||||
if (v >> 64n) throw new Error("That number does not fit in 64 bits.");
|
||||
const low = Number(v & 0xffffffffn);
|
||||
return { ip: [low & 255, (low >>> 8) & 255, (low >>> 16) & 255, low >>> 24].join("."), port: Number(v >> 32n) };
|
||||
}
|
||||
|
||||
/** Pack password to the signed values of szEncPassWord in ioLocalManagerParent.cpp. */
|
||||
export function encodePackPassword(password) {
|
||||
if (!password) throw new Error("Enter a password.");
|
||||
if (password.length >= PACK_PASSWORD_SIZE + 1) throw new Error(`A pack password has at most ${PACK_PASSWORD_SIZE} characters.`);
|
||||
const bytes = toBytes(password);
|
||||
while (bytes.length < PACK_PASSWORD_SIZE) bytes.push(0);
|
||||
return xorBytes(bytes, PACK_KEY).map((b) => (b > 127 ? b - 256 : b));
|
||||
}
|
||||
|
||||
/** Accepts the C array as text: braces, commas and spaces are ignored. */
|
||||
export function decodePackPassword(text) {
|
||||
const values = String(text).match(/-?\d+/g);
|
||||
if (!values || values.length !== PACK_PASSWORD_SIZE) throw new Error(`Enter the ${PACK_PASSWORD_SIZE} values of szEncPassWord.`);
|
||||
const nums = values.map(Number);
|
||||
if (nums.some((v) => v < -128 || v > 255)) throw new Error("Each value must be from -128 to 255.");
|
||||
const out = xorBytes(nums, PACK_KEY);
|
||||
const end = out.indexOf(0);
|
||||
return toText(end < 0 ? out : out.slice(0, end));
|
||||
}
|
||||
|
||||
/** Same result as ls_dbagent.exe -x: the PW value of ls_dbagent.ini. */
|
||||
export function encodeDbPassword(password) {
|
||||
if (!password) throw new Error("Enter a password.");
|
||||
return xorBytes(toBytes(password), DB_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function decodeDbPassword(hex) {
|
||||
const text = String(hex).trim();
|
||||
if (!text || text.length % 2 || !/^[0-9a-f]+$/i.test(text)) throw new Error("Enter the PW value from ls_dbagent.ini.");
|
||||
const bytes = text.match(/../g).map((h) => parseInt(h, 16));
|
||||
return toText(xorBytes(bytes, DB_KEY));
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// The game folder: picked with the File System Access API, remembered in IndexedDB so it can be
|
||||
// opened again after a reload (the browser asks for permission again).
|
||||
import { call } from "./api.js";
|
||||
|
||||
const DB = "losadev-tools";
|
||||
const STORE = "handles";
|
||||
let current = null;
|
||||
const listeners = new Set();
|
||||
|
||||
export const supported = typeof window.showDirectoryPicker === "function";
|
||||
|
||||
function db() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function idb(mode, fn) {
|
||||
const d = await db();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = d.transaction(STORE, mode);
|
||||
const req = fn(tx.objectStore(STORE));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function onChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function game() {
|
||||
return current;
|
||||
}
|
||||
|
||||
async function use(handle) {
|
||||
await call("game:mount", handle);
|
||||
current = { name: handle.name, handle };
|
||||
for (const fn of listeners) fn(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Ask the visitor for the game folder (the folder with rs.iop and map.iop). */
|
||||
export async function pick() {
|
||||
const handle = await window.showDirectoryPicker({ id: "losadev-game", mode: "read" });
|
||||
try {
|
||||
await idb("readwrite", (s) => s.put(handle, "game"));
|
||||
} catch {
|
||||
// private window or blocked storage: works for this visit only
|
||||
}
|
||||
return use(handle);
|
||||
}
|
||||
|
||||
/** The folder from the last visit, if the browser still has it (no permission prompt yet). */
|
||||
export async function remembered() {
|
||||
try {
|
||||
return (await idb("readonly", (s) => s.get("game"))) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open the remembered folder without a prompt when the browser still allows it (Chrome keeps
|
||||
* the permission for the visit, or for good with "Allow on every visit"). Null otherwise.
|
||||
*/
|
||||
export async function reopenQuietly() {
|
||||
const handle = await remembered();
|
||||
if (!handle || typeof handle.queryPermission !== "function") return null;
|
||||
try {
|
||||
if ((await handle.queryPermission({ mode: "read" })) !== "granted") return null;
|
||||
return await use(handle);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-open the remembered folder; must run from a click (the browser shows a permission prompt). */
|
||||
export async function reopen(handle) {
|
||||
const state = await handle.requestPermission({ mode: "read" });
|
||||
if (state !== "granted") throw new Error("Permission to read the folder was not given");
|
||||
return use(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only fallback for browsers without folder handles: Files from <input webkitdirectory>.
|
||||
* The input gives "Folder/sub/file" paths; the first part is the folder itself.
|
||||
*/
|
||||
export async function useFiles(fileList) {
|
||||
const list = [...fileList];
|
||||
if (!list.length) throw new Error("The folder is empty");
|
||||
const top = (list[0].webkitRelativePath || list[0].name).split("/")[0];
|
||||
const items = list.map((f) => ({ path: (f.webkitRelativePath || f.name).split("/").slice(1).join("/") || f.name, file: f }));
|
||||
await call("game:mountFiles", top, items);
|
||||
current = { name: top, handle: null, readOnly: true, items };
|
||||
for (const fn of listeners) fn(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Dev tests: the worker already has the files mounted; just mark the folder as open. */
|
||||
export function markMounted(name, remountFn = null) {
|
||||
current = { name, handle: null, readOnly: true, remountFn };
|
||||
for (const fn of listeners) fn(current);
|
||||
}
|
||||
|
||||
/** After the worker was restarted (a cancelled job): the same folder again, without asking. */
|
||||
export async function remount() {
|
||||
if (!current) return;
|
||||
if (current.handle) await call("game:mount", current.handle);
|
||||
else if (current.items) await call("game:mountFiles", current.name, current.items);
|
||||
else if (current.remountFn) await current.remountFn();
|
||||
}
|
||||
|
||||
export async function forget() {
|
||||
try {
|
||||
await idb("readwrite", (s) => s.delete("game"));
|
||||
} catch {
|
||||
// nothing stored
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/* IOP Manager page: same look as the workbench tools (src/legacy/workbench.css): header with name +
|
||||
description left and actions right, rounded 16px cards on navy, 8px buttons, gold accents. The
|
||||
entry browser (what you pick from) is the right dock; the work area (Preview / Pack info) left. */
|
||||
.iop-area[hidden] { display: none; }
|
||||
.iop,
|
||||
.iop-modal {
|
||||
--surface: #111733;
|
||||
--stage: #0a0f1e;
|
||||
--line: rgba(212, 167, 44, 0.12);
|
||||
--line-2: rgba(212, 167, 44, 0.24);
|
||||
--muted: #a3a9bd;
|
||||
--dim: #6b7391;
|
||||
--sel: rgba(212, 167, 44, 0.16);
|
||||
}
|
||||
.iop {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 0 16px 16px;
|
||||
font: 13px/1.45 var(--body);
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
.iop.drop::after {
|
||||
content: "Drop .iop packs to open them";
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px dashed rgba(212, 167, 44, 0.6);
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 15, 30, 0.8);
|
||||
color: var(--gold-hi);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
.iop-head { display: flex; align-items: flex-start; gap: 8px; padding: 12px 2px 0; }
|
||||
.iop-title { flex: none; max-width: 40%; display: flex; flex-direction: column; gap: 2px; margin-right: 18px; }
|
||||
.iop-title h1 { margin: 0; font: 600 1.4rem/1.15 var(--display); letter-spacing: 0.02em; color: var(--gold-hi); }
|
||||
.iop-title p { margin: 0; max-width: 46ch; color: var(--muted); font-size: 12.5px; line-height: 1.4; }
|
||||
.iop-actions { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 4px; }
|
||||
.iop-sep { width: 1px; height: 22px; background: var(--line-2); margin: 0 2px; }
|
||||
|
||||
.iop-btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: 600 13px var(--body);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.iop-btn:hover:not(:disabled) { border-color: rgba(212, 167, 44, 0.55); background: rgba(212, 167, 44, 0.08); }
|
||||
.iop-btn:focus-visible { outline: 2px solid var(--gold-hi); outline-offset: 2px; }
|
||||
.iop-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.iop-btn.primary { border-color: transparent; background: #d4a72c; color: #17140a; }
|
||||
.iop-btn.primary:hover:not(:disabled) { background: #e2b93f; }
|
||||
.iop-btn.danger { border-color: rgba(240, 138, 126, 0.5); color: var(--bad); }
|
||||
.iop-btn.danger:hover:not(:disabled) { background: rgba(240, 138, 126, 0.1); border-color: var(--bad); }
|
||||
.iop-btn.on { color: var(--gold-hi); border-color: rgba(212, 167, 44, 0.55); background: rgba(212, 167, 44, 0.14); }
|
||||
|
||||
/* ---------- body: work area | dock ---------- */
|
||||
.iop-body { flex: 1; min-height: 0; display: flex; gap: 12px; }
|
||||
.iop-card { border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: var(--surface); min-height: 0; }
|
||||
.iop-work { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--stage); }
|
||||
.iop-dock { width: 440px; min-width: 280px; flex: none; display: flex; flex-direction: column; }
|
||||
.iop-splitter { width: 6px; margin: 0 -9px; flex: none; cursor: col-resize; position: relative; border-radius: 3px; }
|
||||
.iop-splitter::after { content: ""; position: absolute; left: 2px; width: 2px; top: 30%; bottom: 30%; border-radius: 2px; }
|
||||
.iop-splitter:hover::after, .iop-splitter.drag::after { background: rgba(212, 167, 44, 0.6); }
|
||||
|
||||
/* tabs (Preview / Pack info) */
|
||||
.iop-tabs { display: flex; flex: none; gap: 4px; padding: 8px 8px 0; border-bottom: 1px solid var(--line); background: var(--surface); }
|
||||
.iop-tabs button { flex: none; min-width: 120px; height: 34px; border: 0; border-bottom: 2px solid transparent; border-radius: 6px 6px 0 0; background: none; color: var(--muted); font: 600 13px var(--body); cursor: pointer; }
|
||||
.iop-tabs button:hover { color: var(--text); background: rgba(212, 167, 44, 0.06); }
|
||||
.iop-tabs button.on { color: var(--gold-hi); border-bottom-color: #d4a72c; }
|
||||
.iop-pane { flex: 1; min-height: 0; overflow: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.iop-pane[hidden] { display: none; }
|
||||
.iop-pane h2 { margin: 0; font: 600 1.05rem/1.2 var(--display); color: var(--gold-hi); overflow-wrap: anywhere; }
|
||||
.iop-pane h2.sub { margin-top: 8px; }
|
||||
.iop-empty { margin: auto; padding: 24px; max-width: 46ch; text-align: center; color: var(--muted); }
|
||||
.iop-note { margin: 0; font-size: 12.5px; }
|
||||
.iop-pane .ok { color: var(--ok); }
|
||||
.iop-pane .bad, .iop-note.bad { color: var(--bad); }
|
||||
.iop-pane .muted { color: var(--muted); }
|
||||
.iop-pane .mono { font-family: var(--mono); }
|
||||
|
||||
.pv-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; position: relative; z-index: 5; }
|
||||
.pv-head { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 12px; }
|
||||
.pv-head h2 { flex: none; max-width: 60%; }
|
||||
.pv-sub { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12.5px; }
|
||||
#iop-preview-pane, .iop-pane { position: relative; }
|
||||
.pv-meta.pv-float { position: absolute; z-index: 5; top: calc(100% + 8px); right: 0; width: min(420px, 100%); box-sizing: border-box; max-height: 60vh; overflow: auto; padding: 12px 14px; border: 1px solid var(--line-2); border-radius: 12px; background: var(--surface); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); }
|
||||
.pv-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.pv-meta { display: grid; grid-template-columns: max-content 1fr; gap: 3px 16px; margin: 0; font-size: 12.5px; }
|
||||
.pv-meta dt { color: var(--dim); }
|
||||
.pv-meta dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.pv-meta.wide { font-size: 13px; gap: 6px 20px; }
|
||||
.pv-textwrap { flex: 1; min-height: 200px; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||
.pv-encrow { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); background: var(--surface); font-size: 12px; }
|
||||
.pv-encrow label { color: var(--dim); }
|
||||
.pv-enc, .iop-form select, .iop-form input, .iop-pack, .iop-search {
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: #0e1324;
|
||||
color: var(--text);
|
||||
font: 13px var(--body);
|
||||
}
|
||||
.pv-enc:focus, .iop-form select:focus, .iop-form input:focus, .iop-pack:focus, .iop-search:focus { outline: none; border-color: #d4a72c; }
|
||||
.pv-text { flex: 1; margin: 0; padding: 10px 12px; overflow: auto; font: 12px/1.5 var(--mono); white-space: pre; tab-size: 4; color: #d8dbe6; }
|
||||
.pv-text.hex { white-space: pre; }
|
||||
.pv-image { flex: 1; min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #10152a; }
|
||||
.pv-canvas { max-width: 100%; max-height: 60vh; object-fit: contain; image-rendering: auto; }
|
||||
.pv-cap { margin: 0; font-size: 12px; }
|
||||
.iop-bad { margin: 0; padding-left: 18px; max-height: 320px; overflow: auto; font-size: 12px; }
|
||||
|
||||
/* ---------- dock: pack picker, search, folders, list ---------- */
|
||||
.iop-dock-top { display: flex; flex-direction: column; gap: 8px; padding: 10px 10px 8px; border-bottom: 1px solid var(--line); }
|
||||
.iop-pack { width: 100%; height: 34px; font-weight: 600; }
|
||||
.iop-search { width: 100%; height: 32px; }
|
||||
.iop-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 22px; font-size: 12.5px; }
|
||||
.iop-crumbs .crumb { border: 0; background: none; color: #c9b57a; padding: 1px 4px; border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.iop-crumbs .crumb:hover { background: rgba(212, 167, 44, 0.1); color: var(--gold-hi); }
|
||||
.iop-crumbs .crumb:last-child { color: var(--text); font-weight: 600; }
|
||||
.iop-crumbs .crumb-sep { color: var(--dim); }
|
||||
.iop-crumbs .crumb-q { color: var(--text); font-weight: 600; padding: 1px 4px; }
|
||||
.iop-cols, .iop-row { display: grid; grid-template-columns: minmax(0, 1fr) 76px 92px; gap: 8px; align-items: center; padding: 0 12px; }
|
||||
.iop-cols { height: 28px; flex: none; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: #d4a72c; border-bottom: 1px solid var(--line); }
|
||||
.iop-cols .num, .iop-row .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.iop-list { flex: 1; min-height: 0; overflow-y: auto; outline: none; }
|
||||
.iop-row { height: 28px; cursor: default; user-select: none; border-bottom: 1px solid rgba(212, 167, 44, 0.04); }
|
||||
.iop-row:hover { background: rgba(212, 167, 44, 0.06); }
|
||||
.iop-row.sel { background: var(--sel); }
|
||||
.iop-row.cur .nm { color: var(--gold-hi); }
|
||||
.iop-row .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.iop-row.dir .nm { color: #c9b57a; font-weight: 600; }
|
||||
.iop-row .nm small { margin-left: 8px; color: var(--dim); font-size: 11.5px; }
|
||||
.iop-row .muted { color: var(--dim); font-size: 12px; }
|
||||
.iop-row .notes { display: flex; gap: 3px; justify-content: flex-end; overflow: hidden; }
|
||||
.iop-row .tag { font-style: normal; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; border: 1px solid var(--line-2); color: var(--muted); }
|
||||
.iop-row .tag.t-new { color: #17140a; background: #d4a72c; border-color: transparent; }
|
||||
.iop-row .tag.t-xor { color: #9fc6ff; border-color: rgba(159, 198, 255, 0.35); }
|
||||
.iop-row .tag.t-plain { color: var(--dim); }
|
||||
.iop-more { height: 1px; }
|
||||
.iop-list .iop-empty { margin: 24px auto; }
|
||||
.iop-status { flex: none; padding: 6px 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
.iop-modal { width: min(520px, calc(100vw - 32px)); padding: 18px 20px; border-radius: 16px; border: 1px solid rgba(212, 167, 44, 0.35); background: #161c38; color: var(--text); font: 13px/1.5 var(--body); }
|
||||
.iop-modal::backdrop { background: rgba(8, 11, 22, 0.6); }
|
||||
.iop-modal h2 { margin: 0 0 10px; font: 600 1.15rem var(--display); color: var(--gold-hi); }
|
||||
.iop-modal-body p { margin: 0 0 8px; }
|
||||
.iop-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.iop-form { display: flex; flex-direction: column; gap: 6px; }
|
||||
.iop-form label { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.iop-form .row { display: flex; gap: 8px; }
|
||||
.iop-form .muted { color: var(--muted); margin: 0; }
|
||||
.iop-form input, .iop-form select { width: 100%; height: 34px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.iop-title p { display: none; }
|
||||
.iop-dock { width: 360px; }
|
||||
}
|
||||
@media (max-width: 52rem) {
|
||||
/* phones: the page scrolls; header, work area and entry list stack with real heights
|
||||
(the work area had 2px left when everything had to fit the window) */
|
||||
.iop-area { overflow-y: auto; }
|
||||
.iop { height: auto; min-height: 100%; }
|
||||
.iop-body { flex: none; flex-direction: column; }
|
||||
.iop-work { flex: none; height: 70vh; min-height: 320px; }
|
||||
.iop-dock { width: auto !important; min-width: 0; height: 60vh; min-height: 300px; }
|
||||
.iop-splitter { display: none; }
|
||||
.iop-head { flex-wrap: wrap; }
|
||||
.iop-title { max-width: none; margin-right: 0; }
|
||||
.iop-actions { flex: 1 1 100%; justify-content: flex-start; padding-top: 0; }
|
||||
.iop-sep { display: none; }
|
||||
.iop-tabs button { flex: 1; min-width: 0; }
|
||||
.iop-pane { padding: 12px; }
|
||||
.pv-head { flex-basis: 100%; flex-wrap: wrap; row-gap: 2px; }
|
||||
.pv-head h2 { max-width: 100%; }
|
||||
.pv-sub { flex-basis: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
// IOP Manager page (web-native, PLAN phase 8b). Same workbench look as the other tools: header with
|
||||
// name + description on the left and actions on the right; work area on the left (Preview / Pack
|
||||
// info); what you pick from on the right (pack picker, folders, search, entry list).
|
||||
// Worker channels: src/worker/iop-web.js (iopw:*). Changes are made on a copy and downloaded.
|
||||
import { call } from "../api.js";
|
||||
import * as game from "../game.js";
|
||||
import { decode as decodeTexture } from "./texdecode.js";
|
||||
import "./iop-page.css";
|
||||
|
||||
const CHUNK = 300; // rows added to the list at a time (packs have 30–50k entries)
|
||||
const DOCK_KEY = "lsdh-iop-dock";
|
||||
const ENC_KEY = "lsdh-iop-encoding";
|
||||
|
||||
let toast = (t) => console.log(t);
|
||||
let area = null;
|
||||
|
||||
// ---------- state ----------
|
||||
const S = {
|
||||
sessions: [],
|
||||
cur: null, // session id
|
||||
info: null,
|
||||
rows: [], // [display name, size, packed, flags, comment, old copies, changed, raw name]
|
||||
lower: [], // lower-case "/" paths of rows
|
||||
tree: null,
|
||||
cwd: "", // lower-case folder path with trailing "/" ("" = root)
|
||||
query: "",
|
||||
view: [], // items in the list: { dir: lowerPath } | { row: index }
|
||||
shown: 0,
|
||||
sel: new Set(), // "d:<lowerPath>" | "f:<row>"
|
||||
anchor: -1,
|
||||
preview: null, // raw name of the previewed entry
|
||||
previewData: null,
|
||||
tab: "preview",
|
||||
verify: null,
|
||||
};
|
||||
|
||||
// ---------- helpers ----------
|
||||
function el(tag, props = {}, ...children) {
|
||||
const n = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k === "class") n.className = v;
|
||||
else if (k === "text") n.textContent = v;
|
||||
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
|
||||
else n.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const c of children.flat()) if (c !== null && c !== undefined && c !== false) n.append(c);
|
||||
return n;
|
||||
}
|
||||
const fmtSize = (n) => (n >= 1 << 30 ? (n / (1 << 30)).toFixed(2) + " GB" : n >= 1 << 20 ? (n / (1 << 20)).toFixed(1) + " MB" : n >= 1024 ? (n / 1024).toFixed(1) + " KB" : `${n} B`);
|
||||
const fmtInt = (n) => Number(n || 0).toLocaleString("en-US");
|
||||
const hex8 = (n) => (n >>> 0).toString(16).toUpperCase().padStart(8, "0");
|
||||
const fromDos = (date, time) => {
|
||||
const d = new Date(1980 + (date >> 9), ((date >> 5) & 15) - 1, date & 31, time >> 11, (time >> 5) & 63, (time & 31) * 2);
|
||||
return isNaN(d) ? "–" : d.toLocaleString("en-GB");
|
||||
};
|
||||
const baseName = (p) => p.split(/[\\/]/).pop();
|
||||
|
||||
function download(blob, name) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = el("a", { href: url, download: name });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
/** Plain modal: resolves to the clicked button's value (Escape / backdrop = undefined). */
|
||||
function modal(title, body, buttons) {
|
||||
return new Promise((resolve) => {
|
||||
const dlg = el("dialog", { class: "iop-modal" });
|
||||
const close = (v) => {
|
||||
dlg.close();
|
||||
dlg.remove();
|
||||
resolve(v);
|
||||
};
|
||||
dlg.append(
|
||||
el("h2", { text: title }),
|
||||
el("div", { class: "iop-modal-body" }, body),
|
||||
el(
|
||||
"div",
|
||||
{ class: "iop-modal-actions" },
|
||||
buttons.map((b) => el("button", { type: "button", class: `iop-btn${b.kind ? " " + b.kind : ""}`, text: b.label, onclick: () => close(typeof b.value === "function" ? b.value() : b.value) })),
|
||||
),
|
||||
);
|
||||
dlg.addEventListener("cancel", (e) => (e.preventDefault(), close(undefined)));
|
||||
dlg.addEventListener("click", (e) => e.target === dlg && close(undefined));
|
||||
document.body.append(dlg);
|
||||
dlg.showModal();
|
||||
dlg.querySelector("input")?.focus();
|
||||
});
|
||||
}
|
||||
const confirmBox = (title, text, label, kind = "primary") => modal(title, el("p", { text }), [{ label: "Cancel", value: false }, { label, kind, value: true }]);
|
||||
|
||||
/** Files from a picker (<input type=file>); resolves to [] when cancelled. */
|
||||
function pickFiles({ multiple = true, folder = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const input = el("input", { type: "file", multiple, webkitdirectory: folder || undefined, hidden: true });
|
||||
input.addEventListener("change", () => (resolve([...input.files]), input.remove()));
|
||||
input.addEventListener("cancel", () => (resolve([]), input.remove()));
|
||||
document.body.append(input);
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- DOM ----------
|
||||
const D = {};
|
||||
|
||||
function build() {
|
||||
const btn = (id, text, title, onclick, kind = "") => (D[id] = el("button", { type: "button", class: `iop-btn${kind ? " " + kind : ""}`, text, title, onclick }));
|
||||
D.head = el(
|
||||
"header",
|
||||
{ class: "iop-head" },
|
||||
el("div", { class: "iop-title" }, el("h1", { text: "IOP Manager" }), el("p", { text: "Browse, preview and extract pack entries. Changes are made on a copy you download; the game folder is never written." })),
|
||||
el(
|
||||
"div",
|
||||
{ class: "iop-actions" },
|
||||
btn("open", "Open pack…", "Open .iop packs from your PC (any extension; also drop them here)", openPacks),
|
||||
el("span", { class: "iop-sep" }),
|
||||
btn("extract", "Extract", "Extract the selected files and folders (several = one zip)", () => extract(selectedNames())),
|
||||
btn("add", "Add files…", "Add files into the open folder of the pack", () => addDialog([])),
|
||||
btn("del", "Delete", "Remove the selected files and folders", removeSelected),
|
||||
btn("rename", "Rename", "Rename the selected file", renameSelected),
|
||||
el("span", { class: "iop-sep" }),
|
||||
btn("optimize", "Optimize", "Leave old copies and unused space out of the downloaded pack", optimize),
|
||||
btn("verify", "Verify", "Read every entry like the client does", verify),
|
||||
btn("save", "Download pack", "Download the pack with your changes", downloadPack),
|
||||
),
|
||||
);
|
||||
|
||||
// right: what you pick from
|
||||
D.packSel = el("select", { class: "iop-pack", title: "Pack", onchange: () => selectPack(D.packSel.value) });
|
||||
D.crumbs = el("nav", { class: "iop-crumbs", "aria-label": "Folder" });
|
||||
D.search = el("input", { type: "search", class: "iop-search", placeholder: "Search the whole pack (* and ? work)", spellcheck: "false" });
|
||||
let t = 0;
|
||||
D.search.addEventListener("input", () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => setQuery(D.search.value.trim()), 150);
|
||||
});
|
||||
D.list = el("div", { class: "iop-list", tabindex: "0", role: "listbox", "aria-multiselectable": "true" });
|
||||
D.sentinel = el("div", { class: "iop-more" });
|
||||
D.count = el("div", { class: "iop-status" });
|
||||
D.dock = el(
|
||||
"aside",
|
||||
{ class: "iop-card iop-dock" },
|
||||
el("div", { class: "iop-dock-top" }, D.packSel, D.search, D.crumbs),
|
||||
el("div", { class: "iop-cols" }, el("span", { text: "Name" }), el("span", { class: "num", text: "Size" }), el("span", { text: "" })),
|
||||
D.list,
|
||||
D.count,
|
||||
);
|
||||
|
||||
// left: work area with tabs
|
||||
D.tabs = el(
|
||||
"div",
|
||||
{ class: "iop-tabs", role: "tablist" },
|
||||
["preview", "info"].map((tab) => el("button", { type: "button", "data-tab": tab, text: tab === "preview" ? "Preview" : "Pack info", onclick: () => setTab(tab) })),
|
||||
);
|
||||
D.previewPane = el("div", { class: "iop-pane iop-preview" });
|
||||
D.infoPane = el("div", { class: "iop-pane iop-info" });
|
||||
D.work = el("section", { class: "iop-card iop-work" }, D.tabs, D.previewPane, D.infoPane);
|
||||
|
||||
D.splitter = el("div", { class: "iop-splitter", title: "Drag to resize" });
|
||||
D.body = el("div", { class: "iop-body" }, D.work, D.splitter, D.dock);
|
||||
D.root = el("div", { class: "iop" }, D.head, D.body);
|
||||
area.append(D.root);
|
||||
|
||||
// list events
|
||||
D.list.addEventListener("click", onListClick);
|
||||
D.list.addEventListener("dblclick", onListDbl);
|
||||
D.list.addEventListener("keydown", onListKey);
|
||||
new IntersectionObserver((ents) => ents.some((e) => e.isIntersecting) && renderMore(), { root: D.list, rootMargin: "400px" }).observe(D.sentinel);
|
||||
|
||||
// dock width
|
||||
try {
|
||||
const w = Number(localStorage.getItem(DOCK_KEY));
|
||||
if (w >= 280) D.dock.style.width = `${w}px`;
|
||||
} catch {
|
||||
// default width
|
||||
}
|
||||
D.splitter.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
const x0 = e.clientX;
|
||||
const w0 = D.dock.getBoundingClientRect().width;
|
||||
D.splitter.setPointerCapture(e.pointerId);
|
||||
D.splitter.classList.add("drag");
|
||||
const move = (ev) => (D.dock.style.width = `${Math.max(280, Math.min(window.innerWidth * 0.7, w0 + x0 - ev.clientX))}px`);
|
||||
const up = () => {
|
||||
D.splitter.classList.remove("drag");
|
||||
D.splitter.removeEventListener("pointermove", move);
|
||||
try {
|
||||
localStorage.setItem(DOCK_KEY, String(Math.round(D.dock.getBoundingClientRect().width)));
|
||||
} catch {
|
||||
// this visit only
|
||||
}
|
||||
};
|
||||
D.splitter.addEventListener("pointermove", move);
|
||||
D.splitter.addEventListener("pointerup", up, { once: true });
|
||||
});
|
||||
|
||||
// drop packs (or files to add) anywhere on the page
|
||||
D.root.addEventListener("dragover", (e) => {
|
||||
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
||||
e.preventDefault();
|
||||
D.root.classList.add("drop");
|
||||
});
|
||||
D.root.addEventListener("dragleave", (e) => e.target === D.root && D.root.classList.remove("drop"));
|
||||
D.root.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
D.root.classList.remove("drop");
|
||||
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.size > 0 || f.type);
|
||||
if (files.length) openFiles(files, true);
|
||||
});
|
||||
|
||||
setTab(S.tab);
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
// ---------- sessions ----------
|
||||
async function loadSessions(keep = true) {
|
||||
try {
|
||||
S.sessions = await call("iopw:sessions");
|
||||
} catch (err) {
|
||||
S.sessions = [];
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
renderPackSel();
|
||||
const want = keep && S.sessions.some((s) => s.id === S.cur) ? S.cur : S.sessions[0]?.id || null;
|
||||
if (want !== S.cur) await selectPack(want);
|
||||
else await reloadEntries();
|
||||
}
|
||||
|
||||
function renderPackSel() {
|
||||
const opt = (s) => el("option", { value: s.id, text: `${s.name}${s.dirty ? " (changed)" : ""}`, selected: s.id === S.cur || undefined });
|
||||
const games = S.sessions.filter((s) => s.origin === "game");
|
||||
const files = S.sessions.filter((s) => s.origin === "file");
|
||||
D.packSel.replaceChildren(
|
||||
...(S.sessions.length ? [] : [el("option", { value: "", text: game.game() ? "No packs in the game folder" : "No pack open" })]),
|
||||
games.length ? el("optgroup", { label: "Game folder" }, games.map(opt)) : "",
|
||||
files.length ? el("optgroup", { label: "Opened files" }, files.map(opt)) : "",
|
||||
);
|
||||
if (S.cur) D.packSel.value = S.cur;
|
||||
}
|
||||
|
||||
async function selectPack(id) {
|
||||
S.cur = id || null;
|
||||
S.cwd = "";
|
||||
S.query = "";
|
||||
D.search.value = "";
|
||||
S.preview = null;
|
||||
S.previewData = null;
|
||||
S.verify = null;
|
||||
await reloadEntries();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
/** Reads the entry list again (after a change) and keeps the folder when it still exists. */
|
||||
async function reloadEntries() {
|
||||
S.sel.clear();
|
||||
if (!S.cur) {
|
||||
S.rows = [];
|
||||
S.lower = [];
|
||||
S.info = null;
|
||||
S.tree = buildTree([]);
|
||||
return renderAll();
|
||||
}
|
||||
try {
|
||||
const r = await call("iopw:entries", S.cur);
|
||||
S.rows = r.rows;
|
||||
S.info = r.info;
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
S.rows = [];
|
||||
S.info = null;
|
||||
}
|
||||
S.lower = S.rows.map((r) => r[0].replace(/\\/g, "/").toLowerCase());
|
||||
S.tree = buildTree(S.rows);
|
||||
if (!S.tree.has(S.cwd)) S.cwd = "";
|
||||
const sess = S.sessions.find((s) => s.id === S.cur);
|
||||
if (sess && S.info) Object.assign(sess, S.info);
|
||||
renderPackSel();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function setInfo(info) {
|
||||
S.info = info;
|
||||
const sess = S.sessions.find((s) => s.id === info.id);
|
||||
if (sess) Object.assign(sess, info);
|
||||
renderPackSel();
|
||||
}
|
||||
|
||||
// ---------- folders ----------
|
||||
function buildTree(rows) {
|
||||
const tree = new Map([["", { name: "", path: "", dirs: new Map(), files: [] }]]);
|
||||
rows.forEach((r, i) => {
|
||||
const parts = r[0].split(/[\\/]/);
|
||||
let lp = "";
|
||||
let p = "";
|
||||
for (let j = 0; j < parts.length - 1; j++) {
|
||||
if (!parts[j]) continue;
|
||||
const lp2 = `${lp}${parts[j].toLowerCase()}/`;
|
||||
const p2 = `${p}${parts[j]}/`;
|
||||
if (!tree.has(lp2)) {
|
||||
tree.set(lp2, { name: parts[j], path: p2, dirs: new Map(), files: [] });
|
||||
tree.get(lp).dirs.set(lp2, parts[j]);
|
||||
}
|
||||
lp = lp2;
|
||||
p = p2;
|
||||
}
|
||||
tree.get(lp).files.push(i);
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
|
||||
/** Files under a folder (any depth). */
|
||||
const filesUnder = (lp) => S.lower.reduce((out, n, i) => (n.startsWith(lp) ? (out.push(i), out) : out), []);
|
||||
|
||||
function wildcard(q) {
|
||||
const lq = q.toLowerCase().replace(/\\/g, "/");
|
||||
if (!/[*?]/.test(lq)) return (n) => n.includes(lq);
|
||||
const re = new RegExp(lq.replace(/[.+^${}()|[\]]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, "."), "i");
|
||||
return (n) => re.test(n);
|
||||
}
|
||||
|
||||
function setQuery(q) {
|
||||
S.query = q;
|
||||
S.sel.clear();
|
||||
S.anchor = -1;
|
||||
renderList();
|
||||
}
|
||||
|
||||
function goTo(lp) {
|
||||
S.cwd = lp;
|
||||
S.query = "";
|
||||
D.search.value = "";
|
||||
S.sel.clear();
|
||||
S.anchor = -1;
|
||||
renderList();
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
function renderAll() {
|
||||
renderList();
|
||||
renderInfo();
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
function renderCrumbs() {
|
||||
const parts = [el("button", { type: "button", class: "crumb", text: S.info?.name || "Pack", onclick: () => goTo("") })];
|
||||
if (S.query) parts.push(el("span", { class: "crumb-sep", text: "›" }), el("span", { class: "crumb-q", text: "Search results" }));
|
||||
else {
|
||||
let lp = "";
|
||||
for (const seg of S.cwd.split("/").filter(Boolean)) {
|
||||
lp += seg + "/";
|
||||
const target = lp;
|
||||
parts.push(el("span", { class: "crumb-sep", text: "›" }), el("button", { type: "button", class: "crumb", text: S.tree.get(target)?.name || seg, onclick: () => goTo(target) }));
|
||||
}
|
||||
}
|
||||
D.crumbs.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
renderCrumbs();
|
||||
if (S.query) {
|
||||
const match = wildcard(S.query);
|
||||
S.view = [];
|
||||
S.lower.forEach((n, i) => match(n) && S.view.push({ row: i }));
|
||||
} else {
|
||||
const node = S.tree?.get(S.cwd);
|
||||
S.view = node ? [...[...node.dirs.keys()].sort().map((dir) => ({ dir })), ...node.files.slice().sort((a, b) => S.lower[a].localeCompare(S.lower[b])).map((row) => ({ row }))] : [];
|
||||
}
|
||||
S.shown = 0;
|
||||
D.list.replaceChildren();
|
||||
if (!S.cur) D.list.append(el("p", { class: "iop-empty", text: game.game() ? "No pack open." : "Open a game folder on the Game Folder page, or open / drop .iop files here." }));
|
||||
else if (!S.view.length) D.list.append(el("p", { class: "iop-empty", text: S.query ? "Nothing matches." : "This folder is empty." }));
|
||||
D.list.append(D.sentinel);
|
||||
renderMore();
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
function rowEl(item, i) {
|
||||
if (item.dir) {
|
||||
const node = S.tree.get(item.dir);
|
||||
const n = node.dirs.size + node.files.length;
|
||||
return el(
|
||||
"div",
|
||||
{ class: `iop-row dir${S.sel.has("d:" + item.dir) ? " sel" : ""}`, "data-i": i, role: "option" },
|
||||
el("span", { class: "nm", text: node.name + "/" }),
|
||||
el("span", { class: "num muted", text: `${fmtInt(n)} item${n === 1 ? "" : "s"}` }),
|
||||
el("span", {}),
|
||||
);
|
||||
}
|
||||
const r = S.rows[item.row];
|
||||
const notes = [];
|
||||
if (r[6]) notes.push(["new", "Added or changed in this session"]);
|
||||
if (r[5]) notes.push([`+${r[5]}`, `${r[5]} older cop${r[5] === 1 ? "y" : "ies"} in the pack`]);
|
||||
// comment "1" alone is common (Indonesia marks most entries); only the .ini XOR one matters here
|
||||
if (r[4] === "1" && /\.ini/i.test(r[0])) notes.push(["xor", "Comment 1: .ini content XOR-scrambled"]);
|
||||
if (!(r[3] & 1) && S.info?.encrypted) notes.push(["plain", "Not encrypted"]);
|
||||
const dir = r[0].slice(0, r[0].length - baseName(r[0]).length);
|
||||
return el(
|
||||
"div",
|
||||
{ class: `iop-row${S.sel.has("f:" + item.row) ? " sel" : ""}${r[7] === S.preview ? " cur" : ""}`, "data-i": i, role: "option", title: r[0] },
|
||||
el("span", { class: "nm" }, baseName(r[0]), S.query && dir ? el("small", { text: dir }) : ""),
|
||||
el("span", { class: "num", text: fmtSize(r[1]) }),
|
||||
el(
|
||||
"span",
|
||||
{ class: "notes" },
|
||||
notes.map(([t, title]) => el("i", { class: `tag t-${t.replace(/[^a-z]/g, "") || "c"}`, title, text: t })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderMore() {
|
||||
if (S.shown >= S.view.length) return;
|
||||
const frag = document.createDocumentFragment();
|
||||
const end = Math.min(S.view.length, S.shown + CHUNK);
|
||||
for (let i = S.shown; i < end; i++) frag.append(rowEl(S.view[i], i));
|
||||
S.shown = end;
|
||||
D.list.insertBefore(frag, D.sentinel);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const files = S.view.filter((v) => v.row !== undefined).length;
|
||||
const dirs = S.view.length - files;
|
||||
const sel = S.sel.size ? ` · ${fmtInt(S.sel.size)} selected` : "";
|
||||
D.count.textContent = S.cur ? `${S.query ? `${fmtInt(files)} match${files === 1 ? "" : "es"}` : `${dirs ? fmtInt(dirs) + " folders, " : ""}${fmtInt(files)} files`}${sel} · ${fmtInt(S.rows.length)} in the pack` : "";
|
||||
}
|
||||
|
||||
function refreshSelection() {
|
||||
for (const node of D.list.querySelectorAll(".iop-row")) {
|
||||
const item = S.view[Number(node.dataset.i)];
|
||||
node.classList.toggle("sel", S.sel.has(item.dir ? "d:" + item.dir : "f:" + item.row));
|
||||
node.classList.toggle("cur", item.row !== undefined && S.rows[item.row][7] === S.preview);
|
||||
}
|
||||
renderStatus();
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
function updateButtons() {
|
||||
const has = !!S.cur && !!S.info;
|
||||
const canWrite = has && S.info.keyOk;
|
||||
const files = [...S.sel].filter((k) => k.startsWith("f:"));
|
||||
D.extract.disabled = !has || !S.sel.size;
|
||||
D.add.disabled = !canWrite;
|
||||
D.del.disabled = !has || !S.sel.size;
|
||||
D.rename.disabled = !has || !(S.sel.size === 1 && files.length === 1);
|
||||
D.optimize.disabled = !has;
|
||||
D.optimize.classList.toggle("on", !!S.info?.optimize);
|
||||
D.optimize.textContent = S.info?.optimize ? "Optimize: on" : "Optimize";
|
||||
D.verify.disabled = !has;
|
||||
D.save.disabled = !has;
|
||||
D.save.classList.toggle("primary", !!S.info?.dirty);
|
||||
}
|
||||
|
||||
// ---------- list interaction ----------
|
||||
const keyOfItem = (item) => (item.dir ? "d:" + item.dir : "f:" + item.row);
|
||||
|
||||
function onListClick(e) {
|
||||
const node = e.target.closest(".iop-row");
|
||||
if (!node) return;
|
||||
const i = Number(node.dataset.i);
|
||||
const item = S.view[i];
|
||||
const k = keyOfItem(item);
|
||||
if (e.shiftKey && S.anchor >= 0) {
|
||||
if (!e.ctrlKey && !e.metaKey) S.sel.clear();
|
||||
const [a, b] = [Math.min(S.anchor, i), Math.max(S.anchor, i)];
|
||||
for (let j = a; j <= b; j++) S.sel.add(keyOfItem(S.view[j]));
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (S.sel.has(k)) S.sel.delete(k);
|
||||
else S.sel.add(k);
|
||||
S.anchor = i;
|
||||
} else {
|
||||
S.sel.clear();
|
||||
S.sel.add(k);
|
||||
S.anchor = i;
|
||||
if (item.row !== undefined) showPreview(S.rows[item.row][7]);
|
||||
}
|
||||
refreshSelection();
|
||||
}
|
||||
|
||||
function onListDbl(e) {
|
||||
const node = e.target.closest(".iop-row");
|
||||
if (!node) return;
|
||||
const item = S.view[Number(node.dataset.i)];
|
||||
if (item.dir) goTo(item.dir);
|
||||
else extract([S.rows[item.row][7]]);
|
||||
}
|
||||
|
||||
function onListKey(e) {
|
||||
if (e.key === "Delete" && S.sel.size) removeSelected();
|
||||
else if (e.key === "Backspace" && !S.query && S.cwd) goTo(S.cwd.replace(/[^/]+\/$/, ""));
|
||||
else if (e.key === "a" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
S.view.forEach((it) => S.sel.add(keyOfItem(it)));
|
||||
refreshSelection();
|
||||
} else if (e.key === "Enter" && S.sel.size === 1) {
|
||||
const k = [...S.sel][0];
|
||||
if (k.startsWith("d:")) goTo(k.slice(2));
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw entry names of the selection (folders expanded). */
|
||||
function selectedNames() {
|
||||
const rows = new Set();
|
||||
for (const k of S.sel) {
|
||||
if (k.startsWith("f:")) rows.add(Number(k.slice(2)));
|
||||
else for (const i of filesUnder(k.slice(2))) rows.add(i);
|
||||
}
|
||||
return [...rows].map((i) => S.rows[i][7]);
|
||||
}
|
||||
|
||||
// ---------- tabs ----------
|
||||
function setTab(tab) {
|
||||
S.tab = tab;
|
||||
for (const b of D.tabs.children) b.classList.toggle("on", b.dataset.tab === tab);
|
||||
D.previewPane.hidden = tab !== "preview";
|
||||
D.infoPane.hidden = tab !== "info";
|
||||
}
|
||||
|
||||
// ---------- preview ----------
|
||||
const ENCODINGS = [
|
||||
["auto", "Auto"],
|
||||
["euc-kr", "Korean (CP949)"],
|
||||
["big5", "Chinese Trad. (Big5)"],
|
||||
["gbk", "Chinese Simpl. (GBK)"],
|
||||
["windows-874", "Thai (CP874)"],
|
||||
["shift_jis", "Japanese (Shift-JIS)"],
|
||||
["windows-1252", "Western (CP1252)"],
|
||||
["utf-8", "UTF-8"],
|
||||
["utf-16le", "UTF-16 LE"],
|
||||
];
|
||||
|
||||
function encodingOf(bytes, chosen) {
|
||||
if (chosen && chosen !== "auto") return chosen;
|
||||
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
|
||||
if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
|
||||
if (!bytes.some((b) => b > 0x7f)) return "windows-1252";
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, Math.min(bytes.length, 65536 - 3)));
|
||||
return "utf-8";
|
||||
} catch {
|
||||
return "euc-kr";
|
||||
}
|
||||
}
|
||||
|
||||
function hexDump(bytes) {
|
||||
const lines = [];
|
||||
for (let o = 0; o < bytes.length; o += 16) {
|
||||
const chunk = bytes.subarray(o, o + 16);
|
||||
const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")).join(" ");
|
||||
const asc = [...chunk].map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : ".")).join("");
|
||||
lines.push(`${o.toString(16).padStart(8, "0")} ${hex.padEnd(47)} ${asc}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function showPreview(rawName) {
|
||||
S.preview = rawName;
|
||||
S.previewData = null;
|
||||
setTab("preview");
|
||||
renderPreview();
|
||||
try {
|
||||
const p = await call("iopw:preview", S.cur, rawName);
|
||||
if (S.preview !== rawName) return;
|
||||
S.previewData = p;
|
||||
} catch (err) {
|
||||
if (S.preview !== rawName) return;
|
||||
S.previewData = { error: err.message };
|
||||
}
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const p = S.previewData;
|
||||
if (!S.preview) {
|
||||
D.previewPane.replaceChildren(el("p", { class: "iop-empty", text: "Click a file in the list to see it here. Double-click a folder to open it, a file to extract it." }));
|
||||
return;
|
||||
}
|
||||
const row = S.rows.find((r) => r[7] === S.preview);
|
||||
const title = el(
|
||||
"div",
|
||||
{ class: "pv-head" },
|
||||
el("h2", { text: baseName(row ? row[0] : S.preview) }),
|
||||
p && !p.error ? el("span", { class: "pv-sub muted", title: p.name, text: `${fmtSize(p.size)} · ${p.name}` }) : "",
|
||||
);
|
||||
// File details stay out of the way: the editor gets the room, Details opens them over it.
|
||||
const details = el("button", { type: "button", class: `iop-btn${S.detailsOpen ? " on" : ""}`, text: "Details", title: "Show size, CRC, date and flags", disabled: !p || p.error ? true : undefined, onclick: () => { S.detailsOpen = !S.detailsOpen; renderPreview(); } });
|
||||
const actions = el(
|
||||
"div",
|
||||
{ class: "pv-actions" },
|
||||
details,
|
||||
el("button", { type: "button", class: "iop-btn", text: "Extract", onclick: () => extract([S.preview]) }),
|
||||
el("button", { type: "button", class: "iop-btn", text: "Replace…", disabled: !S.info?.keyOk || undefined, title: "Replace this file's content with a file from your PC", onclick: replacePreviewed }),
|
||||
);
|
||||
if (!p) return D.previewPane.replaceChildren(el("div", { class: "pv-top" }, title, actions), el("p", { class: "iop-empty", text: "Reading…" }));
|
||||
const meta = p.error
|
||||
? []
|
||||
: [
|
||||
["Path", p.name],
|
||||
["Size", `${fmtSize(p.size)} (${fmtInt(p.size)} B)`],
|
||||
["Packed", fmtSize(p.packed)],
|
||||
["CRC32", hex8(p.crc)],
|
||||
["Modified", fromDos(p.date, p.time)],
|
||||
["Flags", `0x${p.flags.toString(16).padStart(4, "0")}${p.flags & 1 ? " encrypted" : ""}${p.flags & 8 ? " +descriptor" : ""}`],
|
||||
["Comment", p.comment ? `${p.comment}${p.xorIni ? " (.ini XOR, shown decoded)" : ""}` : "–"],
|
||||
];
|
||||
if (p.changed) meta.push(["State", "Added or changed in this session (in the downloaded pack)"]);
|
||||
let content;
|
||||
if (p.error) content = el("p", { class: "bad", text: `Can't read this entry: ${p.error}` });
|
||||
else if (p.kind === "image") {
|
||||
const canvas = el("canvas", { class: "pv-canvas" });
|
||||
const cap = el("p", { class: "pv-cap muted", text: "Decoding…" });
|
||||
content = el("div", { class: "pv-image" }, canvas, cap);
|
||||
decodeTexture(p.bytes, p.ext)
|
||||
.then((bmp) => {
|
||||
canvas.width = bmp.width;
|
||||
canvas.height = bmp.height;
|
||||
canvas.getContext("2d").drawImage(bmp, 0, 0);
|
||||
cap.textContent = `${bmp.width} × ${bmp.height}`;
|
||||
})
|
||||
.catch((err) => {
|
||||
canvas.remove();
|
||||
cap.textContent = `Can't show this picture: ${err.message}`;
|
||||
cap.className = "pv-cap bad";
|
||||
});
|
||||
} else if (p.kind === "text") {
|
||||
let saved = "auto";
|
||||
try {
|
||||
saved = localStorage.getItem(ENC_KEY) || "auto";
|
||||
} catch {
|
||||
// auto
|
||||
}
|
||||
const sel = el("select", { class: "pv-enc", title: "Text encoding" }, ENCODINGS.map(([v, t]) => el("option", { value: v, text: t, selected: v === saved || undefined })));
|
||||
const pre = el("pre", { class: "pv-text" });
|
||||
const used = el("span", { class: "muted" });
|
||||
const draw = () => {
|
||||
const enc = encodingOf(p.bytes, sel.value);
|
||||
used.textContent = sel.value === "auto" ? `(${enc})` : "";
|
||||
let text;
|
||||
try {
|
||||
text = new TextDecoder(enc).decode(p.bytes);
|
||||
} catch {
|
||||
text = new TextDecoder("windows-1252").decode(p.bytes);
|
||||
}
|
||||
pre.textContent = text + (p.truncated ? `\n\n… first ${fmtSize(p.bytes.length)} of ${fmtSize(p.size)}` : "");
|
||||
};
|
||||
sel.addEventListener("change", () => {
|
||||
try {
|
||||
localStorage.setItem(ENC_KEY, sel.value);
|
||||
} catch {
|
||||
// this visit only
|
||||
}
|
||||
draw();
|
||||
});
|
||||
draw();
|
||||
content = el("div", { class: "pv-textwrap" }, el("div", { class: "pv-encrow" }, el("label", { text: "Encoding" }), sel, used), pre);
|
||||
} else {
|
||||
content = el("div", { class: "pv-textwrap" }, el("div", { class: "pv-encrow muted", text: `Binary file${p.truncated ? `, first ${fmtSize(p.bytes.length)} shown` : ""}` }), el("pre", { class: "pv-text hex", text: hexDump(p.bytes) }));
|
||||
}
|
||||
const panel = S.detailsOpen && meta.length ? el("dl", { class: "pv-meta pv-float" }, meta.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })])) : "";
|
||||
// the panel hangs under the name / buttons row, wherever that row ends (it wraps on narrow screens)
|
||||
D.previewPane.replaceChildren(el("div", { class: "pv-top" }, title, actions, panel), content);
|
||||
}
|
||||
|
||||
// ---------- pack info ----------
|
||||
function renderInfo() {
|
||||
const i = S.info;
|
||||
if (!i) {
|
||||
D.infoPane.replaceChildren(el("p", { class: "iop-empty", text: "No pack open." }));
|
||||
return;
|
||||
}
|
||||
const ch = i.changes;
|
||||
const pending = [ch.added && `${fmtInt(ch.added)} added`, ch.replaced && `${fmtInt(ch.replaced)} replaced`, ch.renamed && `${fmtInt(ch.renamed)} renamed`, ch.removed && `${fmtInt(ch.removed)} removed`, i.optimize && "optimize on"].filter(Boolean);
|
||||
const rows = [
|
||||
["File", i.name],
|
||||
["Source", i.origin === "game" ? "Game folder (read-only)" : "Opened file (read-only)"],
|
||||
["Size", `${fmtSize(i.sourceSize)}${i.dirty ? ` → ${fmtSize(i.size)} after your changes` : ""}`],
|
||||
["Entries", fmtInt(i.live)],
|
||||
["Old copies", i.overwritten ? `${fmtInt(i.overwritten)} (the newest copy of a name is the one the game loads)` : "–"],
|
||||
["Unused space", i.unused ? fmtSize(i.unused) : "–"],
|
||||
["Password", i.key ? i.key.toString() : i.encrypted ? "Unknown: no known password opens this pack" : "Not encrypted"],
|
||||
["Changes", pending.length ? pending.join(", ") : "None"],
|
||||
];
|
||||
const parts = [
|
||||
el("h2", { text: "Pack info" }),
|
||||
el("dl", { class: "pv-meta wide" }, rows.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { class: k === "Password" && !i.keyOk ? "bad" : "", text: v })])),
|
||||
!i.keyOk ? el("p", { class: "iop-note bad", text: "Encrypted entries can't be read or added until you add this client's pack password on the Game Folder page." }) : "",
|
||||
el("p", { class: "iop-note muted", text: "Packs are never changed in place: Download pack writes a new file with your changes. Put it into the client yourself (game closed, keep a backup)." }),
|
||||
el(
|
||||
"div",
|
||||
{ class: "pv-actions" },
|
||||
el("button", { type: "button", class: "iop-btn", text: "Extract all", onclick: () => extract(S.rows.map((r) => r[7]), `${i.name.replace(/\.[^.]+$/, "")}.zip`) }),
|
||||
el("button", { type: "button", class: "iop-btn", text: "Revert changes", disabled: !i.dirty || undefined, onclick: revert }),
|
||||
i.origin === "file" ? el("button", { type: "button", class: "iop-btn", text: "Close file", onclick: closeFile }) : "",
|
||||
),
|
||||
];
|
||||
if (S.verify) {
|
||||
const v = S.verify;
|
||||
parts.push(
|
||||
el("h2", { class: "sub", text: "Verify" }),
|
||||
el("p", { class: v.bad.length ? "bad" : "ok", text: v.bad.length ? `${fmtInt(v.bad.length)} of ${fmtInt(v.checked)} files can't be read.` : `All ${fmtInt(v.checked)} files read correctly.` }),
|
||||
v.bad.length ? el("ul", { class: "iop-bad" }, v.bad.slice(0, 500).map((b) => el("li", {}, el("span", { class: "mono", text: b.name }), el("span", { class: "muted", text: ` ${b.error}` })))) : "",
|
||||
);
|
||||
}
|
||||
D.infoPane.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
// ---------- actions ----------
|
||||
async function openPacks() {
|
||||
const files = await pickFiles({ multiple: true });
|
||||
if (files.length) openFiles(files, false);
|
||||
}
|
||||
|
||||
async function openFiles(files, dropped) {
|
||||
try {
|
||||
const r = await call("iopw:open", files.map((file) => ({ name: file.name, file })));
|
||||
if (r.opened.length) {
|
||||
await loadSessions(false);
|
||||
await selectPack(r.opened[r.opened.length - 1].id);
|
||||
renderPackSel();
|
||||
toast(`Opened ${r.opened.map((o) => o.name).join(", ")}.`, "ok");
|
||||
}
|
||||
if (r.failed.length) {
|
||||
const notPacks = files.filter((f) => r.failed.some((x) => x.name === f.name.replace(/[\\/]/g, "_")));
|
||||
if (dropped && S.cur && S.info?.keyOk && !r.opened.length) return addDialog(notPacks);
|
||||
toast(`${r.failed.map((f) => `${f.name}: ${f.error}`).join(" ")}`, "bad");
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function extract(names, zipName) {
|
||||
if (!names.length) return;
|
||||
let name = zipName;
|
||||
if (!name && names.length > 1) {
|
||||
const folder = S.sel.size === 1 && [...S.sel][0].startsWith("d:") ? S.tree.get([...S.sel][0].slice(2))?.name : null;
|
||||
name = `${S.info.name.replace(/\.[^.]+$/, "")}${folder ? "-" + folder : ""}.zip`;
|
||||
}
|
||||
try {
|
||||
const r = await call("iopw:extract", S.cur, names, name);
|
||||
download(r.blob, r.name);
|
||||
if (r.failed.length) toast(`${fmtInt(r.failed.length)} file(s) couldn't be read (first: ${r.failed[0].name}: ${r.failed[0].error}).`, "bad");
|
||||
else if (r.extracted > 1) toast(`Extracted ${fmtInt(r.extracted)} files into ${r.name}.`, "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
/** Add dialog: folder inside the pack + comment, files from a picker (or dropped). */
|
||||
async function addDialog(initial) {
|
||||
if (!S.cur) return;
|
||||
let items = initial.map((f) => ({ name: f.name.toLowerCase(), file: f }));
|
||||
const prefix = el("input", { type: "text", value: S.tree.get(S.cwd)?.path || "", placeholder: "e.g. config/sp2_item/", spellcheck: "false" });
|
||||
const comment = el(
|
||||
"select",
|
||||
{},
|
||||
el("option", { value: "keep", text: "Same as the file it replaces (none for new files)" }),
|
||||
el("option", { value: "", text: "No comment" }),
|
||||
el("option", { value: "1", text: "Comment 1 (.ini content XOR-scrambled, like the client expects)" }),
|
||||
);
|
||||
const listing = el("p", { class: "muted" });
|
||||
const show = () => (listing.textContent = items.length ? `${fmtInt(items.length)} file(s): ${items.slice(0, 4).map((i) => i.name).join(", ")}${items.length > 4 ? ", …" : ""}` : "No files chosen yet.");
|
||||
show();
|
||||
const choose = (folder) => async () => {
|
||||
const files = await pickFiles({ multiple: true, folder });
|
||||
// a folder: names relative to it (its own name left out), like the desktop's Add folder
|
||||
items = files.map((f) => ({ name: (folder ? (f.webkitRelativePath || f.name).split("/").slice(1).join("/") : f.name).toLowerCase(), file: f }));
|
||||
show();
|
||||
};
|
||||
const body = el(
|
||||
"div",
|
||||
{ class: "iop-form" },
|
||||
el("div", { class: "row" }, el("button", { type: "button", class: "iop-btn", text: "Choose files…", onclick: choose(false) }), el("button", { type: "button", class: "iop-btn", text: "Choose folder…", onclick: choose(true) })),
|
||||
listing,
|
||||
el("label", { text: "Folder inside the pack (added before each name)" }),
|
||||
prefix,
|
||||
el("label", { text: "Entry comment" }),
|
||||
comment,
|
||||
el("p", { class: "muted", text: "A file with the same path as an entry replaces it (the newest copy wins)." }),
|
||||
);
|
||||
const ok = await modal(`Add to ${S.info.name}`, body, [
|
||||
{ label: "Cancel", value: false },
|
||||
{ label: "Add", kind: "primary", value: true },
|
||||
]);
|
||||
if (!ok || !items.length) return;
|
||||
let p = prefix.value.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (p && !p.endsWith("/")) p += "/";
|
||||
try {
|
||||
const r = await call(
|
||||
"iopw:add",
|
||||
S.cur,
|
||||
items.map((i) => ({ name: p + i.name, file: i.file })),
|
||||
comment.value === "keep" ? null : comment.value,
|
||||
);
|
||||
setInfo(r.info);
|
||||
await reloadEntries();
|
||||
toast(`Added ${fmtInt(r.added)}${r.replaced ? `, replaced ${fmtInt(r.replaced)}` : ""}. Download pack to keep the changes.`, "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function replacePreviewed() {
|
||||
const name = S.preview;
|
||||
const [file] = await pickFiles({ multiple: false });
|
||||
if (!file) return;
|
||||
try {
|
||||
setInfo(await call("iopw:replace", S.cur, name, file));
|
||||
await reloadEntries();
|
||||
await showPreview(name);
|
||||
toast(`Replaced ${baseName(name)}. Download pack to keep the change.`, "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
const names = selectedNames();
|
||||
if (!names.length) return;
|
||||
if (!(await confirmBox("Delete entries", `Remove ${fmtInt(names.length)} file(s) from ${S.info.name}? Only the copy you download changes.`, "Delete", "danger"))) return;
|
||||
try {
|
||||
const r = await call("iopw:remove", S.cur, names);
|
||||
setInfo(r.info);
|
||||
if (names.includes(S.preview)) {
|
||||
S.preview = null;
|
||||
renderPreview();
|
||||
}
|
||||
await reloadEntries();
|
||||
toast(`Removed ${fmtInt(r.removed)} file(s).`, "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSelected() {
|
||||
const k = [...S.sel][0];
|
||||
if (!k?.startsWith("f:")) return;
|
||||
const row = S.rows[Number(k.slice(2))];
|
||||
const input = el("input", { type: "text", value: row[0], spellcheck: "false" });
|
||||
const to = await modal("Rename", el("div", { class: "iop-form" }, el("label", { text: "New path inside the pack" }), input), [
|
||||
{ label: "Cancel", value: undefined },
|
||||
{ label: "Rename", kind: "primary", value: () => input.value.trim() },
|
||||
]);
|
||||
if (!to || to === row[0]) return;
|
||||
try {
|
||||
setInfo(await call("iopw:rename", S.cur, row[7], to.toLowerCase()));
|
||||
await reloadEntries();
|
||||
toast(`Renamed to ${to.toLowerCase()}.`, "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function optimize() {
|
||||
try {
|
||||
if (S.info.optimize) {
|
||||
setInfo(await call("iopw:optimize", S.cur, false));
|
||||
renderAll();
|
||||
return toast("Optimize is off: the download keeps the pack's layout.");
|
||||
}
|
||||
const o = await call("iopw:optimizeInfo", S.cur);
|
||||
const text = o.overwritten || o.unused ? `The downloaded ${S.info.name} leaves out ${fmtInt(o.overwritten)} old cop${o.overwritten === 1 ? "y" : "ies"} and ${fmtSize(o.unused)} of unused space: ${fmtSize(o.before)} → about ${fmtSize(o.after)}.` : `${S.info.name} has no old copies or unused space; the download is packed the same.`;
|
||||
if (!(await confirmBox("Optimize", text, "Optimize"))) return;
|
||||
setInfo(await call("iopw:optimize", S.cur, true));
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
try {
|
||||
S.verify = await call("iopw:verify", S.cur);
|
||||
setTab("info");
|
||||
renderInfo();
|
||||
toast(S.verify.bad.length ? `${fmtInt(S.verify.bad.length)} of ${fmtInt(S.verify.checked)} files can't be read.` : `All ${fmtInt(S.verify.checked)} files are OK.`, S.verify.bad.length ? "bad" : "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPack() {
|
||||
try {
|
||||
const r = await call("iopw:build", S.cur);
|
||||
download(r.blob, r.name);
|
||||
toast(`${r.name}: ${fmtSize(r.size)}, ${fmtInt(r.entries)} entries.`, "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
if (!(await confirmBox("Revert changes", `Drop every change made to ${S.info.name} in this visit?`, "Revert", "danger"))) return;
|
||||
try {
|
||||
setInfo(await call("iopw:revert", S.cur));
|
||||
S.preview = null;
|
||||
renderPreview();
|
||||
await reloadEntries();
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function closeFile() {
|
||||
if (S.info.dirty && !(await confirmBox("Close file", `${S.info.name} has changes you haven't downloaded. Close it anyway?`, "Close", "danger"))) return;
|
||||
await call("iopw:close", S.cur);
|
||||
await loadSessions(false);
|
||||
}
|
||||
|
||||
// ---------- page ----------
|
||||
/** Shows the page in its own area of the site layout (created on first use). */
|
||||
export async function showIop(layout, opts = {}) {
|
||||
if (opts.toast) toast = opts.toast;
|
||||
if (!area) {
|
||||
area = el("div", { class: "tool-area iop-area" });
|
||||
layout.append(area);
|
||||
build();
|
||||
game.onChange(() => !area.hidden && loadSessions(false));
|
||||
}
|
||||
area.hidden = false;
|
||||
await loadSessions(true);
|
||||
}
|
||||
|
||||
export function hideIop() {
|
||||
if (area) area.hidden = true;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Texture decoding for the IOP Manager preview: DDS (DXT1/3/5, RGB masks) and TGA → ImageData.
|
||||
// Same logic as the desktop renderer's dds.js (TexDecode), as an ES module so this page doesn't
|
||||
// load the legacy script (which declares a global the legacy loader declares again).
|
||||
const rgb565 = (c) => [(((c >> 11) & 31) * 255) / 31, (((c >> 5) & 63) * 255) / 63, ((c & 31) * 255) / 31];
|
||||
|
||||
function colorBlock(src, o, out, w, h, x, y, dxt1, alphaSrc) {
|
||||
const c0 = src[o] | (src[o + 1] << 8);
|
||||
const c1 = src[o + 2] | (src[o + 3] << 8);
|
||||
const p0 = rgb565(c0);
|
||||
const p1 = rgb565(c1);
|
||||
const pal = [p0, p1];
|
||||
if (!dxt1 || c0 > c1) {
|
||||
pal.push([(2 * p0[0] + p1[0]) / 3, (2 * p0[1] + p1[1]) / 3, (2 * p0[2] + p1[2]) / 3]);
|
||||
pal.push([(p0[0] + 2 * p1[0]) / 3, (p0[1] + 2 * p1[1]) / 3, (p0[2] + 2 * p1[2]) / 3]);
|
||||
} else {
|
||||
pal.push([(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2]);
|
||||
pal.push(null);
|
||||
}
|
||||
const bits = src[o + 4] | (src[o + 5] << 8) | (src[o + 6] << 16) | (src[o + 7] << 24);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const px = x + (i & 3);
|
||||
const py = y + (i >> 2);
|
||||
if (px >= w || py >= h) continue;
|
||||
const c = pal[(bits >>> (2 * i)) & 3];
|
||||
const d = (py * w + px) * 4;
|
||||
if (!c) {
|
||||
out[d] = out[d + 1] = out[d + 2] = out[d + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
out[d] = c[0];
|
||||
out[d + 1] = c[1];
|
||||
out[d + 2] = c[2];
|
||||
out[d + 3] = alphaSrc ? alphaSrc[i] : 255;
|
||||
}
|
||||
}
|
||||
|
||||
function dxt(src, offset, w, h, kind) {
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
const blockBytes = kind === "DXT1" ? 8 : 16;
|
||||
let o = offset;
|
||||
const alpha = new Uint8Array(16);
|
||||
for (let y = 0; y < h; y += 4) {
|
||||
for (let x = 0; x < w; x += 4) {
|
||||
if (o + blockBytes > src.length) return new ImageData(out, w, h);
|
||||
let a = null;
|
||||
if (kind === "DXT3") {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const b = src[o + i];
|
||||
alpha[i * 2] = (b & 15) * 17;
|
||||
alpha[i * 2 + 1] = (b >> 4) * 17;
|
||||
}
|
||||
a = alpha;
|
||||
} else if (kind === "DXT5") {
|
||||
const a0 = src[o];
|
||||
const a1 = src[o + 1];
|
||||
const pal = [a0, a1];
|
||||
if (a0 > a1) for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7);
|
||||
else {
|
||||
for (let i = 1; i < 5; i++) pal.push(((5 - i) * a0 + i * a1) / 5);
|
||||
pal.push(0, 255);
|
||||
}
|
||||
const lo = src[o + 2] | (src[o + 3] << 8) | (src[o + 4] << 16);
|
||||
const hi = src[o + 5] | (src[o + 6] << 8) | (src[o + 7] << 16);
|
||||
for (let i = 0; i < 8; i++) alpha[i] = pal[(lo >> (3 * i)) & 7];
|
||||
for (let i = 0; i < 8; i++) alpha[8 + i] = pal[(hi >> (3 * i)) & 7];
|
||||
a = alpha;
|
||||
}
|
||||
colorBlock(src, o + (kind === "DXT1" ? 0 : 8), out, w, h, x, y, kind === "DXT1", a);
|
||||
o += blockBytes;
|
||||
}
|
||||
}
|
||||
return new ImageData(out, w, h);
|
||||
}
|
||||
|
||||
const shiftOf = (mask) => {
|
||||
if (!mask) return [0, 0];
|
||||
let s = 0;
|
||||
while (!((mask >>> s) & 1)) s++;
|
||||
let n = 0;
|
||||
while ((mask >>> (s + n)) & 1) n++;
|
||||
return [s, n];
|
||||
};
|
||||
|
||||
function masked(src, offset, w, h, bpp, masks, hasAlpha) {
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
const bytes = bpp / 8;
|
||||
const pitch = w * bytes;
|
||||
const chans = masks.map(shiftOf);
|
||||
const dv = new DataView(src.buffer, src.byteOffset, src.byteLength);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const o = offset + y * pitch + x * bytes;
|
||||
if (o + bytes > src.length) return new ImageData(out, w, h);
|
||||
const v = bytes === 4 ? dv.getUint32(o, true) : bytes === 3 ? src[o] | (src[o + 1] << 8) | (src[o + 2] << 16) : dv.getUint16(o, true);
|
||||
const d = (y * w + x) * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const [s, n] = chans[c];
|
||||
out[d + c] = n ? (((v >>> s) & ((1 << n) - 1)) * 255) / ((1 << n) - 1) : 0;
|
||||
}
|
||||
const [as, an] = chans[3];
|
||||
out[d + 3] = hasAlpha && an ? (((v >>> as) & ((1 << an) - 1)) * 255) / ((1 << an) - 1) : 255;
|
||||
}
|
||||
}
|
||||
return new ImageData(out, w, h);
|
||||
}
|
||||
|
||||
export function dds(bytes) {
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (bytes.length < 128 || dv.getUint32(0, true) !== 0x20534444) throw new Error("Not a DDS file");
|
||||
const h = dv.getUint32(12, true);
|
||||
const w = dv.getUint32(16, true);
|
||||
const pf = dv.getUint32(80, true);
|
||||
const fourCC = String.fromCharCode(bytes[84], bytes[85], bytes[86], bytes[87]);
|
||||
if (pf & 4) {
|
||||
if (fourCC === "DXT1" || fourCC === "DXT3" || fourCC === "DXT5") return dxt(bytes, 128, w, h, fourCC);
|
||||
if (fourCC === "DXT2" || fourCC === "DXT4") return dxt(bytes, 128, w, h, fourCC === "DXT2" ? "DXT3" : "DXT5");
|
||||
throw new Error(`DDS format ${fourCC} isn't supported`);
|
||||
}
|
||||
const bpp = dv.getUint32(88, true);
|
||||
const masks = [dv.getUint32(92, true), dv.getUint32(96, true), dv.getUint32(100, true), dv.getUint32(104, true)];
|
||||
return masked(bytes, 128, w, h, bpp, masks, !!(pf & 1));
|
||||
}
|
||||
|
||||
export function tga(bytes) {
|
||||
const idLen = bytes[0];
|
||||
const type = bytes[2];
|
||||
const w = bytes[12] | (bytes[13] << 8);
|
||||
const h = bytes[14] | (bytes[15] << 8);
|
||||
const bpp = bytes[16];
|
||||
const topDown = (bytes[17] & 0x20) !== 0;
|
||||
if (![2, 10].includes(type) || ![24, 32].includes(bpp)) throw new Error("Only true-colour TGA (24/32-bit) is supported");
|
||||
const px = bpp / 8;
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
let o = 18 + idLen;
|
||||
let i = 0;
|
||||
const put = (b, g, r, a) => {
|
||||
const x = i % w;
|
||||
const y = Math.floor(i / w);
|
||||
const d = ((topDown ? y : h - 1 - y) * w + x) * 4;
|
||||
out[d] = r;
|
||||
out[d + 1] = g;
|
||||
out[d + 2] = b;
|
||||
out[d + 3] = a;
|
||||
i++;
|
||||
};
|
||||
while (i < w * h && o < bytes.length) {
|
||||
if (type === 2) {
|
||||
put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255);
|
||||
o += px;
|
||||
continue;
|
||||
}
|
||||
const hdr = bytes[o++];
|
||||
const n = (hdr & 127) + 1;
|
||||
if (hdr & 128) {
|
||||
const c = [bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255];
|
||||
o += px;
|
||||
for (let k = 0; k < n; k++) put(...c);
|
||||
} else {
|
||||
for (let k = 0; k < n; k++) {
|
||||
put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255);
|
||||
o += px;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ImageData(out, w, h);
|
||||
}
|
||||
|
||||
/** Any supported picture → ImageBitmap. */
|
||||
export async function decode(bytes, ext) {
|
||||
const e = (ext || "").toLowerCase();
|
||||
if (e === "dds") return createImageBitmap(dds(bytes));
|
||||
if (e === "tga") return createImageBitmap(tga(bytes));
|
||||
return createImageBitmap(new Blob([bytes]));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Pack passwords on the UI side: the visitor's own (custom) passwords live in this browser's
|
||||
// localStorage and are handed to the worker, which tries them before the built-in ones.
|
||||
import { call } from "./api.js";
|
||||
|
||||
const KEY = "lsdh-tools-custom-keys";
|
||||
|
||||
export function customKeys() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(KEY) || "[]");
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves the list and gives it to the worker. → every key the worker now tries */
|
||||
export async function saveCustomKeys(list) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// storage blocked: the passwords work for this visit only
|
||||
}
|
||||
return call("keys:set", list);
|
||||
}
|
||||
|
||||
/** At start: the saved custom passwords go to the worker before anything opens a pack. */
|
||||
export function sendCustomKeys() {
|
||||
return call("keys:set", customKeys()).catch(() => null);
|
||||
}
|
||||
|
||||
/** Built-in + custom key names, in the order they are tried. */
|
||||
export function listKeys() {
|
||||
return call("keys:list").then((list) => list.map((k) => ({ id: k.id, name: k.name, builtin: !!k.builtin })));
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// window.api for the desktop renderer scripts: the same shape as the Electron preload
|
||||
// ({ call, onTask, onOpenFile, onIopEvent, onMonitor, pathOf }). Channels the browser answers
|
||||
// itself (file dialogs, shell, IOP Manager sessions) are handled here; everything else goes to the
|
||||
// worker. Picked files and folders are mounted in the worker and their virtual path is returned,
|
||||
// so the desktop code keeps working with paths.
|
||||
import { zipSync } from "fflate";
|
||||
import { call as workerCall, post, onWritten, onPersist, onTask } from "../api.js";
|
||||
|
||||
let mountSeq = 0;
|
||||
const nextMount = (kind) => `${kind}${++mountSeq}`;
|
||||
|
||||
// ---------- downloads ----------
|
||||
export function download(name, data, type = "application/octet-stream") {
|
||||
const url = URL.createObjectURL(new Blob([data], { type }));
|
||||
const a = Object.assign(document.createElement("a"), { href: url, download: name });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
}
|
||||
|
||||
// One file → download it; several (an export into a folder) → one zip with their relative paths.
|
||||
onWritten((files) => {
|
||||
if (files.length === 1) return download(files[0].name, files[0].data);
|
||||
const parts = files.map((f) => f.path.split("/").filter(Boolean));
|
||||
let common = parts[0].length - 1;
|
||||
for (const p of parts) {
|
||||
let i = 0;
|
||||
while (i < common && i < p.length - 1 && p[i].toLowerCase() === parts[0][i].toLowerCase()) i++;
|
||||
common = i;
|
||||
}
|
||||
const tree = {};
|
||||
for (let i = 0; i < files.length; i++) tree[parts[i].slice(Math.max(1, common)).join("/")] = files[i].data;
|
||||
const base = parts[0][Math.max(1, common) - 1] || "export";
|
||||
download(`${base}.zip`, zipSync(tree, { level: 6 }), "application/zip");
|
||||
});
|
||||
|
||||
// ---------- /userdata kept in IndexedDB (FX Lab presets and other desktop settings files) ----------
|
||||
const DB = "losadev-tools-userdata";
|
||||
function db() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore("files");
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
onPersist(async (list) => {
|
||||
try {
|
||||
const d = await db();
|
||||
const tx = d.transaction("files", "readwrite");
|
||||
for (const f of list) {
|
||||
if (f.data) tx.objectStore("files").put(f.data, f.path.toLowerCase() + "\u0000" + f.path);
|
||||
else {
|
||||
// deleted: keys are "<lower path>\0<display path>"
|
||||
const range = IDBKeyRange.bound(f.path.toLowerCase() + "\u0000", f.path.toLowerCase() + "\u0001");
|
||||
tx.objectStore("files").delete(range);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("userdata not saved", err);
|
||||
}
|
||||
});
|
||||
export async function restoreUserData() {
|
||||
try {
|
||||
const d = await db();
|
||||
const store = d.transaction("files").objectStore("files");
|
||||
const [keys, values] = await Promise.all(
|
||||
[store.getAllKeys(), store.getAll()].map((req) => new Promise((ok, fail) => ((req.onsuccess = () => ok(req.result)), (req.onerror = () => fail(req.error))))),
|
||||
);
|
||||
const list = keys.map((k, i) => ({ path: String(k).split("\u0000")[1], data: values[i] }));
|
||||
if (list.length) await workerCall("userdata:restore", list);
|
||||
} catch (err) {
|
||||
console.warn("userdata not restored", err);
|
||||
}
|
||||
}
|
||||
|
||||
const readText = (file) => file.text();
|
||||
|
||||
// ---------- pickers ----------
|
||||
function pickFiles({ accept = "", multiple = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const input = Object.assign(document.createElement("input"), { type: "file", accept, multiple });
|
||||
input.addEventListener("change", () => resolve([...input.files]));
|
||||
input.addEventListener("cancel", () => resolve([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
async function mountPickedFiles(files) {
|
||||
if (!files.length) return [];
|
||||
const name = nextMount("pick");
|
||||
await workerCall("vfs:mountFiles", name, files.map((f) => ({ path: f.name, file: f })));
|
||||
return files.map((f) => `/${name}/${f.name}`);
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
if (typeof window.showDirectoryPicker === "function") {
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker({ mode: "read" });
|
||||
return workerCall("vfs:mountHandle", nextMount("folder"), handle);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// read-only fallback
|
||||
const files = await new Promise((resolve) => {
|
||||
const input = Object.assign(document.createElement("input"), { type: "file", webkitdirectory: true });
|
||||
input.addEventListener("change", () => resolve([...input.files]));
|
||||
input.addEventListener("cancel", () => resolve([]));
|
||||
input.click();
|
||||
});
|
||||
if (!files.length) return null;
|
||||
const name = nextMount("folder");
|
||||
await workerCall("vfs:mountFiles", name, files.map((f) => ({ path: f.webkitRelativePath.split("/").slice(1).join("/") || f.name, file: f })));
|
||||
return `/${name}`;
|
||||
}
|
||||
|
||||
const accepts = (exts) => (exts && exts.length ? exts.map((e) => (e === "*" ? "" : `.${e}`)).filter(Boolean).join(",") : "");
|
||||
|
||||
/** "Save as" targets: a virtual path; what the worker writes there comes back as a download. */
|
||||
const saveTarget = (name) => `/out${++mountSeq}/${(name || "file").split(/[\\/]/).pop()}`;
|
||||
|
||||
const LOCAL = {
|
||||
"dialog:file": async () => (await mountPickedFiles(await pickFiles()))[0] || null,
|
||||
"dialog:files": async () => mountPickedFiles(await pickFiles({ multiple: true })),
|
||||
"dialog:openExt": async (_title, _kind, exts) => (await mountPickedFiles(await pickFiles({ accept: accepts(exts) })))[0] || null,
|
||||
"dialog:openXml": async () => (await mountPickedFiles(await pickFiles({ accept: ".xml" })))[0] || null,
|
||||
"dialog:openModel": async () => (await mountPickedFiles(await pickFiles({ accept: ".msh,.ani,.skl,.txt" })))[0] || null,
|
||||
"dialog:openIop": async () => (await mountPickedFiles(await pickFiles({ accept: ".iop" })))[0] || null,
|
||||
"dialog:openZip": async () => (await mountPickedFiles(await pickFiles({ accept: ".zip" })))[0] || null,
|
||||
"dialog:folder": async () => pickFolder(),
|
||||
"dialog:saveAs": async (name) => saveTarget(name),
|
||||
"dialog:savePng": async (name) => saveTarget(name || "image.png"),
|
||||
"dialog:saveXml": async (name) => saveTarget(name || "uiimageset.xml"),
|
||||
"dialog:saveIop": async (name) => saveTarget(name || "pack.iop"),
|
||||
"file:writeBase64": async (file, b64) => {
|
||||
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
download(String(file).split(/[\\/]/).pop(), bin);
|
||||
},
|
||||
// dialogs inside kept desktop channels
|
||||
"fxlab:openJson": async () => {
|
||||
const [f] = await pickFiles({ accept: ".json" });
|
||||
return f ? readText(f) : null;
|
||||
},
|
||||
"fxlab:saveJson": async (name, text) => {
|
||||
const file = String(name || "preset.json").split(/[\\/]/).pop();
|
||||
download(/\.json$/i.test(file) ? file : `${file}.json`, new TextEncoder().encode(text), "application/json");
|
||||
return file;
|
||||
},
|
||||
"fx:openFile": async () => (await mountPickedFiles(await pickFiles({ accept: ".txt" })))[0] || null,
|
||||
"dat:pickFile": async (_title, kinds) => (await mountPickedFiles(await pickFiles({ accept: { dat: ".dat", sheet: ".xlsx,.csv,.tsv,.txt" }[kinds] || "" })))[0] || null,
|
||||
"shell:show": async () => {},
|
||||
"shell:open": async () => {},
|
||||
"app:reload": async () => location.reload(),
|
||||
};
|
||||
|
||||
async function call(channel, ...args) {
|
||||
if (LOCAL[channel]) return LOCAL[channel](...args);
|
||||
return workerCall(channel, ...args);
|
||||
}
|
||||
|
||||
// Desktop event hooks: tasks and IOP / Monitor events don't exist in the web version.
|
||||
const noop = () => {};
|
||||
|
||||
let dropSeq = 0;
|
||||
/** A dropped File as a path (sync, like Electron's webUtils.getPathForFile). */
|
||||
function pathOf(file) {
|
||||
const name = `drop${++dropSeq}`;
|
||||
post("vfs:mountFiles", name, [{ path: file.name, file }]);
|
||||
return `/${name}/${file.name}`;
|
||||
}
|
||||
|
||||
export function installBridge() {
|
||||
window.api = Object.freeze({ call, onTask, onOpenFile: noop, onIopEvent: noop, onMonitor: noop, pathOf });
|
||||
return restoreUserData();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// ---- web overrides (src/legacy/host-web-after.js), loaded after the app.js copies ----
|
||||
|
||||
// Same as the desktop, but an unpacked game folder (no .iop) is a "dir" source, not "packs".
|
||||
function profileSource() {
|
||||
return WS?.gameDir ? { kind: WS.sourceKind === "dir" ? "dir" : "packs", folder: WS.gameDir, name: WS.name } : null;
|
||||
}
|
||||
function profileSourceOption() {
|
||||
const s = profileSource();
|
||||
return s ? [{ value: s, title: `${WS.name} — game folder`, sub: s.kind === "dir" ? "unpacked files" : "its packs" }] : [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// ---- web additions (src/legacy/host-web.js) ----
|
||||
// The desktop app.js kept these as its own state; in the web version they come from the site.
|
||||
|
||||
// The active "profile": the game folder opened on the Game Folder page (mounted at /game in the
|
||||
// worker). profileSource() (copied below) turns it into the tools' default asset source.
|
||||
var WS = null;
|
||||
/** Called by the site when the game folder changes. kind: "packs" (has .iop) or "dir" (unpacked). */
|
||||
function setWebGameFolder(name, kind) {
|
||||
WS = name ? { id: "web", name, gameDir: "/game", sourceKind: kind || "packs" } : null;
|
||||
}
|
||||
|
||||
// IOP Manager is not part of the web tools yet: no open sessions.
|
||||
var IOP = { files: [], active: null, rows: [], cwd: "", profiles: [], index: null, expanded: new Set(), mode: "folders" };
|
||||
|
||||
/** Turn <i data-icon="x"> placeholders into SVG icons and wire data-cmd buttons (app.js did this once at load). */
|
||||
function hydrateLegacy(root) {
|
||||
for (const n of root.querySelectorAll("[data-icon]")) n.replaceWith(Object.assign(icon(n.dataset.icon), { className: `ic ${n.className}`.trim() }));
|
||||
for (const b of root.querySelectorAll("[data-cmd]")) b.addEventListener("click", () => $(b.dataset.cmd).onclick?.());
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Loads the desktop renderer (public/legacy, generated by tools/extract-legacy.mjs) once, the way
|
||||
// the desktop window does: every view's markup in place, the shared overlays, host.js (app.js
|
||||
// helpers), then every tool script in index.html order. Pages then just show one view.
|
||||
import { installBridge } from "./bridge.js";
|
||||
import { applyWorkbench } from "./workbench.js";
|
||||
import "./workbench.css";
|
||||
|
||||
let ready = null;
|
||||
let root = null;
|
||||
|
||||
const BASE = "/legacy/";
|
||||
// every build (and every dev page load) asks for fresh copies: the desktop scripts change with the patches
|
||||
const VER = import.meta.env.DEV ? String(Date.now()) : __LEGACY_VER__;
|
||||
const v = (url) => `${url}?v=${VER}`;
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = src;
|
||||
s.async = false;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error(`Could not load ${src}`));
|
||||
document.body.append(s);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCss(href) {
|
||||
return new Promise((resolve) => {
|
||||
const l = Object.assign(document.createElement("link"), { rel: "stylesheet", href });
|
||||
l.onload = resolve;
|
||||
l.onerror = resolve;
|
||||
document.head.append(l);
|
||||
});
|
||||
}
|
||||
|
||||
async function boot(container) {
|
||||
const restored = installBridge();
|
||||
const [manifest, chrome] = await Promise.all([fetch(v(BASE + "manifest.json")).then((r) => r.json()), fetch(v(BASE + "chrome.html")).then((r) => r.text())]);
|
||||
const views = await Promise.all(manifest.views.map((v) => fetch(`${BASE}views/${v}.html?v=${VER}`).then((r) => r.text())));
|
||||
await loadCss(v(BASE + "legacy.css"));
|
||||
await restored; // FX Lab presets etc. are back in /userdata before any tool reads them
|
||||
|
||||
root = document.createElement("div");
|
||||
root.className = "lsx";
|
||||
root.id = "lsx-root";
|
||||
// Hidden tab bar: the tool scripts listen for clicks on their [data-view] button to start up.
|
||||
const tabs = manifest.views.map((v) => `<button type="button" data-view="${v}"></button>`).join("");
|
||||
root.innerHTML = `<nav id="tabs" hidden>${tabs}</nav><main id="app">${views.join("\n")}</main>${chrome}`;
|
||||
container.append(root);
|
||||
|
||||
await loadScript(v(BASE + "host.js"));
|
||||
if (gameFolder) window.setWebGameFolder(gameFolder.name, gameFolder.kind);
|
||||
// host.js turned the placeholders of the overlays and views into icons once scripts can use icon()
|
||||
window.hydrateLegacy(root);
|
||||
for (const f of manifest.order) await loadScript(v(`${BASE}js/${f}`));
|
||||
applyWorkbench(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Load once; resolves to the legacy root element. */
|
||||
export function ensureLegacy(container) {
|
||||
if (!ready) ready = boot(container);
|
||||
return ready;
|
||||
}
|
||||
|
||||
/** Show one desktop view (and start it like a tab click on the desktop). */
|
||||
export async function showView(container, view) {
|
||||
const r = await ensureLegacy(container);
|
||||
r.hidden = false;
|
||||
for (const v of r.querySelectorAll("#app > .view")) v.classList.toggle("on", v.id === `view-${view}`);
|
||||
r.querySelector(`#tabs [data-view="${view}"]`)?.click();
|
||||
}
|
||||
|
||||
/** True once a tool view was shown (its script may have read the game folder already). */
|
||||
export function legacyStarted() {
|
||||
return !!ready;
|
||||
}
|
||||
|
||||
export function hideLegacy() {
|
||||
if (root) root.hidden = true;
|
||||
}
|
||||
|
||||
// The game folder can open before the desktop scripts are loaded; it is applied once they are.
|
||||
let gameFolder = null;
|
||||
|
||||
/** Tell the tools which game folder is open (their default source). */
|
||||
export function setGameFolder(name, kind) {
|
||||
gameFolder = name ? { name, kind } : null;
|
||||
if (typeof window.setWebGameFolder === "function") window.setWebGameFolder(name, kind);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
/* The web version's own layout for the desktop tool views (see workbench.js). Everything is scoped
|
||||
with the doubled #lsx-root.lsx so it wins over the generated legacy.css. Plain: flat colours, no
|
||||
gradients, no decorative icons. */
|
||||
|
||||
#lsx-root.lsx .view.wb {
|
||||
padding: 0 16px 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* legacy.css gives the monitor details window a standard scrollbar-color, which in Chrome replaces
|
||||
the shared ::-webkit-scrollbar style from style.css; drop it so it looks like every other list. */
|
||||
#lsx-root.lsx .mon-info { scrollbar-color: auto; }
|
||||
|
||||
/* ---------- header: name + source on the left, actions on the right ---------- */
|
||||
#lsx-root.lsx .wb-head {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 12px 2px 0;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
#lsx-root.lsx .wb-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-right: 18px;
|
||||
}
|
||||
#lsx-root.lsx .wb-title h1 {
|
||||
margin: 0;
|
||||
font: 600 1.4rem/1.15 var(--display, "Cinzel", serif);
|
||||
letter-spacing: 0.02em;
|
||||
color: #f3dc9a;
|
||||
}
|
||||
#lsx-root.lsx .wb-title p {
|
||||
margin: 0;
|
||||
max-width: 46ch;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
#lsx-root.lsx .wb-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#lsx-root.lsx .wb-head .iconbtn,
|
||||
#lsx-root.lsx .wb-head .btn,
|
||||
#lsx-root.lsx .wb-head .pill {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
#lsx-root.lsx .wb-head .iconbtn.only {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
#lsx-root.lsx .wb-head .iconbtn:hover:not(:disabled),
|
||||
#lsx-root.lsx .wb-head .btn:hover:not(:disabled) {
|
||||
border-color: rgba(212, 167, 44, 0.55);
|
||||
background: rgba(212, 167, 44, 0.08);
|
||||
}
|
||||
#lsx-root.lsx .wb-head .btn.primary {
|
||||
border-color: transparent;
|
||||
background: #d4a72c;
|
||||
color: #17140a;
|
||||
}
|
||||
#lsx-root.lsx .wb-head .btn.primary:hover:not(:disabled) {
|
||||
background: #e2b93f;
|
||||
filter: none;
|
||||
}
|
||||
#lsx-root.lsx .wb-head .btn.primary:disabled {
|
||||
background: rgba(212, 167, 44, 0.18);
|
||||
color: rgba(243, 220, 154, 0.5);
|
||||
}
|
||||
/* the source: a gold icon button first in the actions; its label is the tooltip */
|
||||
#lsx-root.lsx .wb-head .wb-source {
|
||||
color: #f3dc9a;
|
||||
border-color: rgba(212, 167, 44, 0.4);
|
||||
background: rgba(212, 167, 44, 0.08);
|
||||
}
|
||||
#lsx-root.lsx .wb-head .wb-source > span { display: none; }
|
||||
/* the open file's name takes the free space and shortens with "…" instead of wrapping the bar */
|
||||
#lsx-root.lsx .wb-head .ims-title {
|
||||
flex: 1 1 0;
|
||||
min-width: 60px;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
#lsx-root.lsx .wb-head .ims-title > span:first-child {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#lsx-root.lsx .wb-actions:has(.ims-title) > .spacer { display: none; }
|
||||
#lsx-root.lsx .wb-head { flex-wrap: nowrap; align-items: flex-start; }
|
||||
#lsx-root.lsx .wb-head .wb-title { flex: none; max-width: 40%; }
|
||||
#lsx-root.lsx .wb-actions {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
#lsx-root.lsx .wb-actions > .spacer { flex: 1; }
|
||||
|
||||
/* ---------- body: work area | inspector | library ---------- */
|
||||
#lsx-root.lsx .wb > .split {
|
||||
gap: 12px;
|
||||
}
|
||||
#lsx-root.lsx .wb > .split > .main-col {
|
||||
order: 1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #0a0f1e;
|
||||
}
|
||||
/* tools with a list only or a panel only (no dock) */
|
||||
#lsx-root.lsx .wb > .split > .ims-side,
|
||||
#lsx-root.lsx .wb > .split > .ims-left {
|
||||
order: 3;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
#lsx-root.lsx .wb > .split > .splitter { order: 2; }
|
||||
#lsx-root.lsx .wb > .split > .ims-side[hidden] { display: none; }
|
||||
|
||||
/* the dock: Library and Properties share one column, switched with tabs */
|
||||
#lsx-root.lsx .wb > .split > .wb-dock {
|
||||
order: 3;
|
||||
width: 360px;
|
||||
min-width: 260px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
#lsx-root.lsx .wb-tabs {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
padding: 8px 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
#lsx-root.lsx .wb-tabs button {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: 600 13px var(--body, inherit);
|
||||
cursor: pointer;
|
||||
}
|
||||
#lsx-root.lsx .wb-tabs button:hover:not(:disabled) { color: var(--text); background: rgba(212, 167, 44, 0.06); }
|
||||
#lsx-root.lsx .wb-tabs button.on { color: #f3dc9a; border-bottom-color: #d4a72c; }
|
||||
#lsx-root.lsx .wb-tabs button:disabled { opacity: 0.4; cursor: default; }
|
||||
#lsx-root.lsx .wb-dock > .ims-left,
|
||||
#lsx-root.lsx .wb-dock > .ims-side {
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
#lsx-root.lsx .wb-dock > .ims-side { overflow: auto; }
|
||||
#lsx-root.lsx .wb-dock[data-tab="lib"] > .ims-side,
|
||||
#lsx-root.lsx .wb-dock[data-tab="side"] > .ims-left { display: none; }
|
||||
|
||||
#lsx-root.lsx .wb > .split > .splitter {
|
||||
width: 6px;
|
||||
margin: 0 -9px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#lsx-root.lsx .wb > .split > .splitter::after {
|
||||
left: 2px;
|
||||
width: 2px;
|
||||
top: 30%;
|
||||
bottom: 30%;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
#lsx-root.lsx .wb > .split > .splitter:hover::after,
|
||||
#lsx-root.lsx .wb > .split > .splitter.drag::after {
|
||||
background: rgba(212, 167, 44, 0.6);
|
||||
}
|
||||
|
||||
/* library: underline tabs, roomy search */
|
||||
#lsx-root.lsx .wb .ims-left .seg {
|
||||
padding: 0;
|
||||
gap: 2px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
#lsx-root.lsx .wb .ims-left .seg button {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
}
|
||||
#lsx-root.lsx .wb .ims-left .seg button.on {
|
||||
color: #f3dc9a;
|
||||
border-bottom-color: #d4a72c;
|
||||
background: none;
|
||||
}
|
||||
#lsx-root.lsx .wb .ims-left .search {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#lsx-root.lsx .wb .ims-left .search input {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ---------- the viewer's toolbar floats over the stage ---------- */
|
||||
#lsx-root.lsx .wb .main-col > .bar.wb-float {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: calc(100% - 24px);
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
padding: 4px 6px;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
background: rgba(17, 23, 51, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#lsx-root.lsx .wb .main-col > .bar.wb-float .spacer { display: none; }
|
||||
#lsx-root.lsx .wb .main-col > .bar.wb-float > * { flex: none; }
|
||||
#lsx-root.lsx .wb .main-col > .bar.wb-float .iconbtn {
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#lsx-root.lsx .wb .main-col > .bar.wb-float .iconbtn.toggle.on {
|
||||
color: #f3dc9a;
|
||||
background: rgba(212, 167, 44, 0.14);
|
||||
}
|
||||
#lsx-root.lsx .wb .main-col [class*="stage"] { position: relative; }
|
||||
#lsx-root.lsx .wb .main-col .wb-info {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
left: 12px;
|
||||
bottom: 10px;
|
||||
max-width: calc(100% - 28px);
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 15, 30, 0.75);
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
#lsx-root.lsx .wb .main-col .wb-info:empty { display: none; }
|
||||
|
||||
/* ---------- data views (Items, Quests, Tables): same rule, what you pick from is on the right ---------- */
|
||||
#lsx-root.lsx .wb-source-line {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #c9b57a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60ch;
|
||||
}
|
||||
#lsx-root.lsx .wb .it-body,
|
||||
#lsx-root.lsx .wb .dt-body,
|
||||
#lsx-root.lsx .wb .q-body,
|
||||
#lsx-root.lsx .wb .q-sub > .q-list:only-child {
|
||||
gap: 12px;
|
||||
}
|
||||
#lsx-root.lsx .wb .it-body > *,
|
||||
#lsx-root.lsx .wb .dt-body > *,
|
||||
#lsx-root.lsx .wb .q-body > *,
|
||||
#lsx-root.lsx .wb .q-sub > * {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
/* the detail panels scroll (the card rule above would clip them) */
|
||||
#lsx-root.lsx .wb .it-detail,
|
||||
#lsx-root.lsx .wb .q-detail,
|
||||
#lsx-root.lsx .wb .dt-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
#lsx-root.lsx .wb .q-sub > .q-body,
|
||||
#lsx-root.lsx .wb .q-sub > .q-body ~ * { border: 0; background: none; border-radius: 0; overflow: visible; }
|
||||
|
||||
/* Items: categories become a row of chips over the item detail and the list */
|
||||
#lsx-root.lsx .wb .it-body {
|
||||
grid-template-columns: minmax(320px, 400px) minmax(360px, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-areas: "cats cats" "detail list";
|
||||
}
|
||||
#lsx-root.lsx .wb .it-cats {
|
||||
grid-area: cats;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
overflow: visible;
|
||||
}
|
||||
#lsx-root.lsx .wb .it-cats button {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
#lsx-root.lsx .wb .it-cats button.on {
|
||||
color: #17140a;
|
||||
background: #d4a72c;
|
||||
border-color: transparent;
|
||||
}
|
||||
#lsx-root.lsx .wb .it-cats button.on * { color: inherit; }
|
||||
#lsx-root.lsx .wb .it-detail { grid-area: detail; border-left: 1px solid var(--line); }
|
||||
#lsx-root.lsx .wb .it-list { grid-area: list; }
|
||||
|
||||
/* Tables: the table names are the library on the right */
|
||||
#lsx-root.lsx .wb .dt-body { grid-template-columns: minmax(0, 1fr) 250px; }
|
||||
#lsx-root.lsx .wb .dt-list { order: 2; border-right: 0; }
|
||||
#lsx-root.lsx .wb .dt-main { order: 1; }
|
||||
|
||||
/* Quests: the editor on the left, the list (the wider part) on the right */
|
||||
#lsx-root.lsx .wb .q-body { grid-template-columns: minmax(380px, 520px) minmax(0, 1fr); }
|
||||
/* list toolbars that wrap grow instead of cutting their second row off */
|
||||
#lsx-root.lsx .wb .q-list > .bar.nav,
|
||||
#lsx-root.lsx .wb .dt-main > .bar.nav {
|
||||
height: auto;
|
||||
min-height: 44px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
#lsx-root.lsx .wb .q-list > .bar.nav .search { flex: 1 1 200px; }
|
||||
#lsx-root.lsx .wb .q-body > .q-detail { order: 1; border-left: 1px solid var(--line); }
|
||||
#lsx-root.lsx .wb .q-body > .q-list { order: 2; }
|
||||
|
||||
/* Tables: Export client / server .dat */
|
||||
#lsx-root.lsx .wb-export { display: inline-flex; align-items: center; gap: 4px; padding-left: 8px; border-left: 1px solid var(--line); }
|
||||
#lsx-root.lsx .wb-export > .muted { font-size: 12px; padding-right: 2px; }
|
||||
|
||||
/* Heroes: while the hero loads, a plain "Loading hero…" instead of the half-built model */
|
||||
#lsx-root.lsx .hero-stage { position: relative; }
|
||||
#lsx-root.lsx .hero-stage.wb-loading > canvas { visibility: hidden; }
|
||||
#lsx-root.lsx .hero-stage.wb-loading::after {
|
||||
content: "Loading hero…";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---------- narrow windows ---------- */
|
||||
@media (max-width: 1180px) {
|
||||
#lsx-root.lsx .wb > .split > .wb-dock { width: 300px; }
|
||||
#lsx-root.lsx .wb-title p { display: none; }
|
||||
}
|
||||
|
||||
/* a tool that loads by itself (Heroes): the stage and its buttons wait, the hero list stays usable; a clear message on the stage */
|
||||
#lsx-root.lsx .view.wb-busy { cursor: progress; }
|
||||
#lsx-root.lsx .view.wb-busy > .split > .main-col { pointer-events: none; }
|
||||
#lsx-root.lsx .hero-stage.wb-loading::after {
|
||||
content: "Loading hero…";
|
||||
color: #f3dc9a;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
background: rgba(10, 15, 30, 0.6);
|
||||
}
|
||||
/* Heroes: the work column scrolls (the rounded card around it would clip the action list) */
|
||||
#lsx-root.lsx .wb > .split > .main-col.hero-main { overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
/* Heroes › Flow: more air and clear groups (spacing / borders only, same markup).
|
||||
Card = head line | animation + timed events | hits; each hit is its own box with its details
|
||||
in even columns. */
|
||||
#lsx-root.lsx .flow { gap: 16px; padding-bottom: 8px; }
|
||||
#lsx-root.lsx .flow-summary { padding: 10px 14px; font-size: 12.5px; line-height: 1.45; background: var(--surface); }
|
||||
#lsx-root.lsx .flow-card { padding: 0; gap: 0; border-radius: 12px; background: var(--surface); overflow: hidden; }
|
||||
#lsx-root.lsx .flow-head {
|
||||
align-items: center;
|
||||
gap: 6px 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
#lsx-root.lsx .flow-head b { font-size: 15px; color: #f3dc9a; }
|
||||
#lsx-root.lsx .flow-head .muted { font-size: 12px; }
|
||||
#lsx-root.lsx .flow-check { font-size: 11.5px; padding: 2px 8px; border-radius: 6px; border: 1px solid currentColor; }
|
||||
#lsx-root.lsx .flow-row {
|
||||
align-items: flex-start;
|
||||
gap: 12px 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
#lsx-root.lsx .flow-row > .flow-arrow { align-self: center; padding-top: 14px; }
|
||||
#lsx-root.lsx .flow-step { gap: 5px; }
|
||||
#lsx-root.lsx .flow-step > em,
|
||||
#lsx-root.lsx .flow-l > em { font-size: 10.5px; letter-spacing: 0.04em; }
|
||||
#lsx-root.lsx .flow-events {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 10px 16px;
|
||||
min-width: 260px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
/* the "·" before the events: the column's left border separates them now */
|
||||
#lsx-root.lsx .flow-row > .flow-arrow:has(+ .flow-events) { display: none; }
|
||||
#lsx-root.lsx .flow-chip { padding: 3px 8px; font-size: 11.5px; max-width: 100%; }
|
||||
#lsx-root.lsx .flow-chip > span:first-child { overflow: hidden; text-overflow: ellipsis; }
|
||||
#lsx-root.lsx .flow-l { min-width: 0; gap: 4px; }
|
||||
#lsx-root.lsx .flow-hits {
|
||||
gap: 10px;
|
||||
padding: 12px 16px 14px;
|
||||
border-left: 0;
|
||||
}
|
||||
#lsx-root.lsx .flow-hits::before {
|
||||
content: "Hits";
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
#lsx-root.lsx .flow-hit {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 2px solid rgba(212, 167, 44, 0.55);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
#lsx-root.lsx .flow-hit.bad { border-left-color: rgba(255, 107, 91, 0.7); }
|
||||
#lsx-root.lsx .flow-hit-head { gap: 6px 8px; }
|
||||
#lsx-root.lsx .flow-hit-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 10px 16px;
|
||||
margin: 10px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
#lsx-root.lsx .flow-hit-body:empty { display: none; }
|
||||
#lsx-root.lsx .flow-hit-body .flow-l { display: flex; }
|
||||
#lsx-root.lsx .flow-hit-body .flow-l > .flow-chip { align-self: flex-start; }
|
||||
#lsx-root.lsx .flow-events .flow-l > .flow-chip { align-self: flex-start; }
|
||||
|
||||
/* one loading message at a time: the page's overlay wins over the stage's own */
|
||||
body.is-busy #lsx-root.lsx .hero-stage.wb-loading::after { content: ""; }
|
||||
@@ -0,0 +1,387 @@
|
||||
// The web version's own layout for the desktop tool views (owner, 2026-11-07: the desktop
|
||||
// placement looked too similar). The desktop markup and scripts stay unchanged; this only moves a
|
||||
// few elements and adds classes, keeping every id the tool scripts use:
|
||||
// - header: tool name + description, the source button as a chip under it, actions on the right
|
||||
// - work area first, inspector next to it, the library (list) on the right
|
||||
// - the viewer's toolbar floats over the stage
|
||||
// - splitters work in either direction and remember their width
|
||||
// - UI Image Sets loads the game folder's uiimageset.xml by itself (no Load button)
|
||||
import { ALL } from "../tools.js";
|
||||
|
||||
const WIDTHS_KEY = "lsdh-tools-widths";
|
||||
|
||||
function h(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
function readWidths() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(WIDTHS_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveWidth(id, w) {
|
||||
try {
|
||||
const all = readWidths();
|
||||
all[id] = Math.round(w);
|
||||
localStorage.setItem(WIDTHS_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
// storage blocked: the width lasts for this visit
|
||||
}
|
||||
}
|
||||
|
||||
function headers(root) {
|
||||
const byView = Object.fromEntries(ALL.filter((t) => t.view).map((t) => [t.view, t]));
|
||||
for (const view of root.querySelectorAll("#app > .view")) {
|
||||
const tool = byView[view.id.replace(/^view-/, "")];
|
||||
const head = view.querySelector(":scope > .bar.top");
|
||||
if (!tool || !head) continue;
|
||||
view.classList.add("wb");
|
||||
head.classList.add("wb-head");
|
||||
const title = h("div", "wb-title");
|
||||
title.append(h("h1", "", tool.name), h("p", "", tool.desc));
|
||||
// the source button: a small icon button at the start of the actions (owner: no own row for it)
|
||||
const src = head.querySelector(':scope > [id$="-src"]');
|
||||
if (src) {
|
||||
src.classList.add("wb-source", "only");
|
||||
const label = src.querySelector('[id$="-src-label"]');
|
||||
const sync = () => {
|
||||
if (!label) return;
|
||||
src.setAttribute("aria-label", `Source: ${label.textContent}`);
|
||||
if (!src.dataset.wbTitled || src.title === src.dataset.wbTitled) src.title = src.dataset.wbTitled = `Source: ${label.textContent} (click to change)`;
|
||||
};
|
||||
sync();
|
||||
if (label) new MutationObserver(sync).observe(label, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
// data views (Items, Quests, Tables): their own small title goes, the source line stays
|
||||
const zone = head.querySelector(":scope > .zone-title");
|
||||
if (zone) {
|
||||
const line = zone.querySelector(".muted");
|
||||
if (line) {
|
||||
line.classList.add("wb-source-line");
|
||||
title.append(line);
|
||||
}
|
||||
zone.remove();
|
||||
}
|
||||
// everything else: one action group on the right that wraps within itself
|
||||
const actions = h("div", "wb-actions");
|
||||
actions.append(...head.childNodes);
|
||||
head.append(title, actions);
|
||||
const nav = view.querySelector(":scope > .split > .main-col > .bar.nav");
|
||||
if (nav) {
|
||||
nav.classList.add("wb-float");
|
||||
// sizes / counts text: a caption in the stage's bottom-left corner, not in the pill
|
||||
// inside the stage itself, so it never sits on a timeline or help line under the stage
|
||||
const stage = nav.parentElement.querySelector(':scope > [class*="stage"]') || nav.parentElement;
|
||||
for (const info of nav.querySelectorAll(":scope > span.muted, :scope > .ims-texinfo")) {
|
||||
info.classList.add("wb-info");
|
||||
stage.append(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- one dock on the right: Library and Properties as tabs (owner: the stage was too narrow) ----------
|
||||
const TAB_KEY = "lsdh-tools-dock";
|
||||
|
||||
function docks(root) {
|
||||
let saved = {};
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem(TAB_KEY) || "{}");
|
||||
} catch {
|
||||
// defaults
|
||||
}
|
||||
for (const split of root.querySelectorAll(".wb > .split")) {
|
||||
const lib = split.querySelector(":scope > .ims-left");
|
||||
const side = split.querySelector(":scope > .ims-side");
|
||||
if (!lib || !side) continue;
|
||||
const view = split.parentElement.id.replace(/^view-/, "");
|
||||
const dock = h("div", "wb-dock");
|
||||
dock.id = `wb-dock-${view}`;
|
||||
const tabs = h("div", "wb-tabs");
|
||||
const tLib = h("button", "", "Library");
|
||||
const tSide = h("button", "", "Properties");
|
||||
tLib.type = tSide.type = "button";
|
||||
tabs.append(tLib, tSide);
|
||||
for (const s of split.querySelectorAll(":scope > .splitter")) s.remove();
|
||||
const splitter = h("div", "splitter");
|
||||
splitter.dataset.target = dock.id;
|
||||
splitter.dataset.edge = "right";
|
||||
dock.append(tabs, lib, side);
|
||||
split.append(splitter, dock);
|
||||
|
||||
const show = (which, remember = true) => {
|
||||
dock.dataset.tab = which;
|
||||
tLib.classList.toggle("on", which === "lib");
|
||||
tSide.classList.toggle("on", which === "side");
|
||||
if (remember) {
|
||||
saved[view] = which;
|
||||
try {
|
||||
localStorage.setItem(TAB_KEY, JSON.stringify(saved));
|
||||
} catch {
|
||||
// fine for this visit
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
};
|
||||
tLib.onclick = () => show("lib");
|
||||
tSide.onclick = () => !side.hidden && show("side");
|
||||
const syncHidden = () => {
|
||||
tSide.disabled = side.hidden;
|
||||
if (side.hidden && dock.dataset.tab === "side") show("lib", false);
|
||||
};
|
||||
// a panel that appears when something is opened (Models, Maps, UI…) comes to the front once
|
||||
new MutationObserver(() => {
|
||||
const was = tSide.disabled;
|
||||
syncHidden();
|
||||
if (was && !side.hidden) show("side", false);
|
||||
}).observe(side, { attributes: true, attributeFilter: ["hidden"] });
|
||||
show(saved[view] === "side" && !side.hidden ? "side" : "lib", false);
|
||||
syncHidden();
|
||||
}
|
||||
}
|
||||
|
||||
function splitters(root) {
|
||||
const widths = readWidths();
|
||||
for (const [id, w] of Object.entries(widths)) {
|
||||
const t = root.querySelector(`#${CSS.escape(id)}`);
|
||||
if (t) t.style.width = `${w}px`;
|
||||
}
|
||||
root.addEventListener("pointerdown", (e) => {
|
||||
const s = e.target.closest(".split > .splitter");
|
||||
if (!s || e.button !== 0) return;
|
||||
const target = document.getElementById(s.dataset.target);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
const r = target.getBoundingClientRect();
|
||||
// the panel can sit on either side of its splitter in this layout
|
||||
const sign = r.left >= s.getBoundingClientRect().left ? -1 : 1;
|
||||
const x0 = e.clientX;
|
||||
const w0 = r.width;
|
||||
s.setPointerCapture(e.pointerId);
|
||||
s.classList.add("drag");
|
||||
let w = w0;
|
||||
const move = (ev) => {
|
||||
w = Math.max(200, Math.min(640, w0 + sign * (ev.clientX - x0)));
|
||||
target.style.width = `${w}px`;
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
};
|
||||
const up = () => {
|
||||
s.removeEventListener("pointermove", move);
|
||||
s.classList.remove("drag");
|
||||
saveWidth(target.id, w);
|
||||
};
|
||||
s.addEventListener("pointermove", move);
|
||||
s.addEventListener("pointerup", up, { once: true });
|
||||
s.addEventListener("pointercancel", up, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- UI Image Sets: no pack picker / Load button, the game folder's file opens by itself ----------
|
||||
function imageSets(root) {
|
||||
const load = root.querySelector("#ims-load");
|
||||
if (!load) return;
|
||||
const pill = root.querySelector("#ims-session")?.closest("label");
|
||||
for (const n of [load, pill, root.querySelector("#ims-entry")]) if (n) n.hidden = true;
|
||||
const empty = root.querySelector("#ims-empty p");
|
||||
if (empty) empty.textContent = "Open your client folder on the Game Folder page and xml/uiimageset.xml opens here by itself, with its textures from the packs. Or use Open file… for an imageset XML on your PC.";
|
||||
|
||||
let loadedFor = null;
|
||||
let busy = false;
|
||||
const auto = async () => {
|
||||
const ws = typeof WS !== "undefined" ? WS : null; // global of the desktop host (host.js)
|
||||
if (!ws || busy || loadedFor === ws.name) return;
|
||||
if (typeof IMS !== "undefined" && IMS.doc && IMS.dirty) return; // never drop unsaved edits
|
||||
busy = true;
|
||||
try {
|
||||
if (ws.sourceKind === "dir") {
|
||||
const r = await window.api.call("ims:loadFile", `${ws.gameDir}/xml/uiimageset.xml`);
|
||||
await setTexSource({ kind: "dir", folder: ws.gameDir });
|
||||
await loadDoc(r);
|
||||
} else {
|
||||
const list = await imsSessions();
|
||||
if (!list.some((s) => s.name.toLowerCase() === "rs.iop")) return;
|
||||
await load.onclick();
|
||||
}
|
||||
loadedFor = ws.name;
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
root.querySelector('#tabs [data-view="ui"]')?.addEventListener("click", () => setTimeout(auto, 0));
|
||||
}
|
||||
|
||||
// ---------- Heroes: hide the half-built model while the hero loads (default body, extra head…) ----------
|
||||
function heroLoading(root) {
|
||||
const status = root.querySelector("#hero-play-status");
|
||||
const stage = root.querySelector("#hero-stage");
|
||||
if (!status || !stage) return;
|
||||
const view = root.querySelector("#view-hero");
|
||||
const sync = () => {
|
||||
const on = /^loading/i.test(status.textContent.trim());
|
||||
stage.classList.toggle("wb-loading", on);
|
||||
view?.classList.toggle("wb-busy", on); // no clicks on the hero while it loads
|
||||
};
|
||||
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
|
||||
sync();
|
||||
}
|
||||
|
||||
// ---------- Tables: export the open table as the client's or the server's .dat ----------
|
||||
// (owner: client and server .dat of a table differ — columns and header version)
|
||||
function tablesExport(root) {
|
||||
const bar = root.querySelector("#dt-tools");
|
||||
if (!bar) return;
|
||||
const api = () => window.api;
|
||||
const make = (side) => {
|
||||
const b = h("button", "iconbtn");
|
||||
b.type = "button";
|
||||
b.title = side === "client" ? "Write this table as the client's .dat (resource/tables, the client's columns)" : "Write this table as the server's .dat (ls_gamesvr/config/tables, the server's columns)";
|
||||
b.append(h("span", "", side === "client" ? "Client" : "Server"));
|
||||
b.onclick = () => run(side);
|
||||
return b;
|
||||
};
|
||||
async function run(side) {
|
||||
const T = window.DT;
|
||||
const S = T?.cur;
|
||||
if (!S) return toast("Open a table first.", true);
|
||||
S.commitEdit?.();
|
||||
const file = `${S.name}.dat`.toLowerCase();
|
||||
const hit = (T.lists[side] || []).find((it) => it.name.toLowerCase() === file);
|
||||
let target = null;
|
||||
if (hit) target = side === "client" ? { kind: "pack", src: T.src || profileSource(), key: hit.key } : { kind: "file", file: hit.path };
|
||||
else {
|
||||
const ok = await confirmBox(
|
||||
`${side === "client" ? "Client" : "Server"} layout`,
|
||||
`No ${side} copy of ${S.name}.dat is open (${side === "client" ? "game folder packs" : "Server folder"}). Pick that side's ${S.name}.dat once; its columns and header are used as the layout.`,
|
||||
"Pick .dat",
|
||||
);
|
||||
if (!ok) return;
|
||||
const picked = await api().call("dat:pickFile", `The ${side}'s ${S.name}.dat`, "dat");
|
||||
if (!picked) return;
|
||||
target = { kind: "file", file: picked };
|
||||
}
|
||||
try {
|
||||
const r = await api().call("dat:exportFor", S.id, S.rows.map((x) => x.v), target, `${S.name}.dat`);
|
||||
const notes = [
|
||||
r.missing.length ? `filled from the ${side} copy: ${r.missing.join(", ")}` : "",
|
||||
r.dropped.length ? `not in the ${side} table: ${r.dropped.join(", ")}` : "",
|
||||
].filter(Boolean);
|
||||
toast(`${S.name}.dat for the ${side}: ${r.rows} rows, header ${r.headerVersion}${notes.length ? ` · ${notes.join(" · ")}` : ""}.`);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
const imp = bar.querySelector("#dt-import");
|
||||
const group = h("div", "wb-export");
|
||||
const cb = make("client");
|
||||
const sb = make("server");
|
||||
group.append(h("span", "muted", ".dat for"), cb, sb);
|
||||
// the desktop Export writes a spreadsheet: say so, and follow its enabled state
|
||||
const xl = bar.querySelector("#dt-export");
|
||||
const xlLabel = xl?.querySelector("span");
|
||||
if (xlLabel) xlLabel.textContent = "Excel / CSV";
|
||||
const sync = () => (cb.disabled = sb.disabled = !!xl?.disabled);
|
||||
if (xl) new MutationObserver(sync).observe(xl, { attributes: true, attributeFilter: ["disabled"] });
|
||||
sync();
|
||||
if (imp) imp.before(group);
|
||||
else bar.append(group);
|
||||
}
|
||||
|
||||
// ---------- Textures: the game folder's textures by default ----------
|
||||
function textures(root) {
|
||||
const GAME_TEX = "/game-textures";
|
||||
// folders picked in an earlier visit are gone after a reload (browser mounts): forget them
|
||||
try {
|
||||
const d = localStorage.getItem("tex:dir") || "";
|
||||
if (/^\/(folder|pick|drop|out)\d/i.test(d)) localStorage.removeItem("tex:dir");
|
||||
} catch {
|
||||
// storage blocked
|
||||
}
|
||||
const open = root.querySelector("#tex-open");
|
||||
if (!open) return;
|
||||
const btn = h("button", "iconbtn");
|
||||
btn.type = "button";
|
||||
btn.title = "The textures of the opened game folder (resource/texture in the packs, or the unpacked files)";
|
||||
const ic = h("i");
|
||||
ic.dataset.icon = "box";
|
||||
btn.append(ic, h("span", "", "Game folder"));
|
||||
window.hydrateLegacy?.(btn);
|
||||
// the list's footer: the game folder's name instead of the virtual path
|
||||
const dirLabel = root.querySelector("#tex-dir");
|
||||
if (dirLabel)
|
||||
new MutationObserver(() => {
|
||||
if (dirLabel.textContent === GAME_TEX) dirLabel.textContent = `Game folder: ${typeof WS !== "undefined" && WS ? WS.name : ""}`;
|
||||
}).observe(dirLabel, { childList: true, characterData: true, subtree: true });
|
||||
btn.onclick = () => {
|
||||
if (typeof WS === "undefined" || !WS) return toast("Open your client folder on the Game Folder page first.", true);
|
||||
window.TEX?.openFolder(GAME_TEX);
|
||||
};
|
||||
open.before(btn);
|
||||
open.querySelector("span") && (open.querySelector("span").textContent = "Other folder…");
|
||||
root.querySelector('#tabs [data-view="tex"]')?.addEventListener("click", () =>
|
||||
setTimeout(() => {
|
||||
const T = window.TEX;
|
||||
if (T && !T.dir && typeof WS !== "undefined" && WS) T.openFolder(GAME_TEX);
|
||||
}, 0),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Anim Studio: only animated models in its Models list ----------
|
||||
// (owner: static models can't be animated, so listing them only leads to models that won't open
|
||||
// here). Model configs outside resource/model/animodel/ have no skeleton; meshes, skeletons and
|
||||
// animations stay listed.
|
||||
// One row per name: the list shows only the name, so a file that is in map.iop AND rs.iop looked
|
||||
// listed twice. (2014 / 2019 test clients: no full path is in both packs; the doubles are the same
|
||||
// file name in two folders, e.g. map.iop resource/texture/arthur_as_end.ani next to rs.iop
|
||||
// resource/animation/arthur_as_end.ani.) The row kept is the copy the tools load when a model asks
|
||||
// for that name: assets.js pick() with the folder Anim Studio passes (readNamed: animation / mesh /
|
||||
// skeleton) → resource/<folder>/ first, then any …/<folder>/…, then the first hit in pack order
|
||||
// (rs.iop, map.iop, then other packs by name, like the client; mdl:list returns rows in that order). Model configs
|
||||
// are read by path suffix (first hit in pack order), so for them the first row wins.
|
||||
const ANIM_FOLDER = { ani: "animation", msh: "mesh", skl: "skeleton", model: "model" };
|
||||
function animRank(r) {
|
||||
const f = ANIM_FOLDER[r[3]];
|
||||
const p = "/" + String(r[4] || "").replace(/\\/g, "/").toLowerCase();
|
||||
const dir = p.slice(0, p.lastIndexOf("/") + 1);
|
||||
return !f ? 2 : dir.endsWith(`/resource/${f}/`) ? 0 : dir.includes(`/${f}/`) ? 1 : 2;
|
||||
}
|
||||
function animModels() {
|
||||
const A = window.ANS;
|
||||
if (!A) return;
|
||||
let rows = A.rows || [];
|
||||
const animated = (r) => r[3] !== "model" || /(^|\/)model\/animodel\//i.test(String(r[4] || ""));
|
||||
const unique = (list) => {
|
||||
const best = new Map(); // kind|name → index in list
|
||||
list.forEach((r, i) => {
|
||||
const k = `${r[3]}|${String(r[1] || "").toLowerCase()}`;
|
||||
const j = best.get(k);
|
||||
if (j === undefined || animRank(r) < animRank(list[j])) best.set(k, i);
|
||||
});
|
||||
const keep = new Set(best.values());
|
||||
return list.filter((_, i) => keep.has(i));
|
||||
};
|
||||
Object.defineProperty(A, "rows", {
|
||||
configurable: true,
|
||||
get: () => rows,
|
||||
set: (v) => (rows = Array.isArray(v) ? unique(v.filter(animated)) : v),
|
||||
});
|
||||
A.rows = rows;
|
||||
}
|
||||
|
||||
export function applyWorkbench(root) {
|
||||
animModels();
|
||||
textures(root);
|
||||
tablesExport(root);
|
||||
heroLoading(root);
|
||||
headers(root);
|
||||
docks(root);
|
||||
splitters(root);
|
||||
imageSets(root);
|
||||
}
|
||||
+536
@@ -0,0 +1,536 @@
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource/cinzel/600.css";
|
||||
import "@fontsource/cinzel/700.css";
|
||||
import "./style.css";
|
||||
import { call, onBusy, setBusyNote, restartWorker, onRestart } from "./api.js";
|
||||
import { restoreUserData } from "./legacy/bridge.js";
|
||||
import * as game from "./game.js";
|
||||
import { customKeys, saveCustomKeys, listKeys, sendCustomKeys } from "./keys.js";
|
||||
import { GROUPS, byId } from "./tools.js";
|
||||
import { showView, hideLegacy, setGameFolder, legacyStarted } from "./legacy/loader.js";
|
||||
import { showIop, hideIop } from "./iop/iop-page.js";
|
||||
import { showCipher, hideCipher } from "./cipher/cipher-page.js";
|
||||
|
||||
// ---------- small DOM helpers ----------
|
||||
export function el(tag, props = {}, ...children) {
|
||||
const n = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k === "class") n.className = v;
|
||||
else if (k === "text") n.textContent = v;
|
||||
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
|
||||
else n.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const c of children.flat()) if (c !== null && c !== undefined && c !== false) n.append(c);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function toast(text, kind = "info") {
|
||||
const t = el("div", { class: `toast ${kind}`, role: "status", text });
|
||||
document.getElementById("toasts").append(t);
|
||||
setTimeout(() => t.classList.add("out"), 4200);
|
||||
setTimeout(() => t.remove(), 4700);
|
||||
}
|
||||
|
||||
const fmtSize = (n) => (n >= 1 << 30 ? (n / (1 << 30)).toFixed(2) + " GB" : n >= 1 << 20 ? (n / (1 << 20)).toFixed(1) + " MB" : (n / 1024).toFixed(0) + " KB");
|
||||
const fmtInt = (n) => n.toLocaleString("en-US");
|
||||
|
||||
// ---------- sidebar ----------
|
||||
function renderSide(active) {
|
||||
const side = document.getElementById("side");
|
||||
side.replaceChildren(
|
||||
...GROUPS.map((g) =>
|
||||
el(
|
||||
"div",
|
||||
{ class: "side-group" },
|
||||
el("div", { class: "side-label", text: g.label }),
|
||||
...g.tools.map((t) =>
|
||||
el(
|
||||
"a",
|
||||
{ class: `side-link${t.id === active ? " active" : ""}${t.id !== "home" && !t.view && !t.page ? " soon" : ""}`, href: `#/${t.id === "home" ? "" : t.id}` },
|
||||
el("span", { text: t.name }),
|
||||
t.id !== "home" && !t.view && !t.page ? el("span", { class: "badge", text: "Soon" }) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- game status in the header ----------
|
||||
function renderStatus() {
|
||||
const box = document.getElementById("game-status");
|
||||
const g = game.game();
|
||||
box.replaceChildren(g ? el("span", { class: "status-chip ok", title: "Game folder", text: g.name }) : el("span", { class: "status-chip", text: "No game folder" }));
|
||||
}
|
||||
|
||||
// ---------- pages ----------
|
||||
async function homePage(main) {
|
||||
const packsBox = el("div", { class: "packs" });
|
||||
const actions = el("div", { class: "actions page-actions" });
|
||||
|
||||
const showPacks = async () => {
|
||||
packsBox.replaceChildren(el("p", { class: "muted", text: "Reading packs…" }));
|
||||
try {
|
||||
const list = await call("packs:list");
|
||||
if (!list.length) {
|
||||
packsBox.replaceChildren(el("p", { class: "warn", text: "No .iop packs in this folder. Pick the client folder that contains rs.iop and map.iop." }));
|
||||
return;
|
||||
}
|
||||
const locked = list.filter((p) => !p.error && p.encrypted && !p.key);
|
||||
packsBox.replaceChildren(
|
||||
locked.length
|
||||
? el(
|
||||
"div",
|
||||
{ class: "notice bad" },
|
||||
el("b", { text: `No known password opens ${locked.map((p) => p.name).join(", ")}.` }),
|
||||
el("span", { text: " This client uses its own pack password. Add it under Pack passwords below and the packs are read again." }),
|
||||
)
|
||||
: "",
|
||||
el(
|
||||
"table",
|
||||
{ class: "grid" },
|
||||
el("thead", {}, el("tr", {}, ...["Pack", "Size", "Entries", "Old copies", "Password"].map((h) => el("th", { text: h })))),
|
||||
el(
|
||||
"tbody",
|
||||
{},
|
||||
...list.map((p) =>
|
||||
p.error
|
||||
? el("tr", {}, el("td", { text: p.name }), el("td", { class: "bad", colspan: 4, text: p.error }))
|
||||
: el(
|
||||
"tr",
|
||||
{},
|
||||
el("td", { class: "mono", text: p.name }),
|
||||
el("td", { class: "num", text: fmtSize(p.size) }),
|
||||
el("td", { class: "num", text: fmtInt(p.live) }),
|
||||
el("td", { class: "num", text: p.overwritten ? fmtInt(p.overwritten) : "–" }),
|
||||
el("td", { class: p.key ? "ok" : p.encrypted ? "bad" : "muted", text: p.key || (p.encrypted ? "Unknown password" : "Not encrypted") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
packsBox.replaceChildren(el("p", { class: "bad", text: err.message }));
|
||||
}
|
||||
};
|
||||
|
||||
const openBtn = el("button", {
|
||||
class: "btn gold",
|
||||
text: game.game() ? "Open another folder" : "Open game folder",
|
||||
onclick: async () => {
|
||||
try {
|
||||
await game.pick();
|
||||
openBtn.textContent = "Open another folder";
|
||||
showPacks();
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") toast(err.message, "bad");
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.append(openBtn);
|
||||
|
||||
if (!game.game()) {
|
||||
const last = await game.remembered();
|
||||
if (last)
|
||||
actions.append(
|
||||
el("button", {
|
||||
class: "btn ghost",
|
||||
text: `Reopen “${last.name}”`,
|
||||
onclick: async () => {
|
||||
try {
|
||||
await game.reopen(last);
|
||||
showPacks();
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// same layout as the tools: name + description left, actions right, cards below
|
||||
main.replaceChildren(
|
||||
el(
|
||||
"header",
|
||||
{ class: "page-head" },
|
||||
el("div", { class: "page-title" }, el("h1", { text: "Game Folder" }), el("p", { text: "Your client folder, the one with rs.iop and map.iop. Read on your PC in this tab, never uploaded." })),
|
||||
game.supported
|
||||
? actions
|
||||
: el(
|
||||
"div",
|
||||
{},
|
||||
el("p", { class: "warn", text: "This browser can only read a folder, not save into it. For saving, use a recent Chrome, Edge or Opera on a PC." }),
|
||||
el(
|
||||
"label",
|
||||
{ class: "btn gold" },
|
||||
"Pick game folder (read-only)",
|
||||
el("input", {
|
||||
type: "file",
|
||||
webkitdirectory: true,
|
||||
hidden: true,
|
||||
onchange: async (e) => {
|
||||
try {
|
||||
await game.useFiles(e.target.files);
|
||||
showPacks();
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
el(
|
||||
"div",
|
||||
{ class: "page-grid" },
|
||||
el("section", { class: "panel wide" }, el("h2", { text: "Packs" }), packsBox),
|
||||
loadPanel(),
|
||||
passwordsPanel(() => (game.game() ? showPacks() : null)),
|
||||
),
|
||||
);
|
||||
if (game.game()) showPacks();
|
||||
else packsBox.replaceChildren(el("p", { class: "muted", text: "Open a game folder to see its packs." }));
|
||||
}
|
||||
|
||||
let busyCancel = null; // set while a job that can be cancelled runs (Full load)
|
||||
|
||||
// After a cancelled job the worker is new: same folder, same passwords, same saved presets.
|
||||
onRestart(async () => {
|
||||
await sendCustomKeys();
|
||||
await restoreUserData();
|
||||
await game.remount();
|
||||
});
|
||||
|
||||
// ---------- loading mode: what a tool needs (default) or everything right after the folder opens ----------
|
||||
const LOAD_KEY = "lsdh-tools-load";
|
||||
export const loadMode = () => {
|
||||
try {
|
||||
return localStorage.getItem(LOAD_KEY) === "full" ? "full" : "demand";
|
||||
} catch {
|
||||
return "demand";
|
||||
}
|
||||
};
|
||||
|
||||
/** Reads every list and index the tools use, one step after the other, with the step on the overlay. */
|
||||
async function preloadAll() {
|
||||
const g = game.game();
|
||||
if (!g) return toast("Open a game folder first.", "bad");
|
||||
let kind = "packs";
|
||||
try {
|
||||
kind = await call("game:kind");
|
||||
} catch {
|
||||
// packs
|
||||
}
|
||||
const src = { kind: kind === "dir" ? "dir" : "packs", folder: "/game", name: g.name }; // same as the tools' source
|
||||
const steps = [
|
||||
["Packs", "packs:list"],
|
||||
["Models list", "mdl:list", src],
|
||||
["Model index (skeletons)", "mdl:findSkeleton", src, { bones: [] }],
|
||||
["Heroes", "hero:list", src],
|
||||
["Items", "items:load", src, {}],
|
||||
["Quests", "q:load", src, {}],
|
||||
["Effects", "fx:list", src],
|
||||
["Maps", "map:list", src],
|
||||
["UI windows", "xml:list", src],
|
||||
["UI texts", "wnd:texts", src],
|
||||
["Textures", "tex:list", "/game-textures"],
|
||||
["Costumes", "cos:info", src, "armor"],
|
||||
];
|
||||
const t0 = performance.now();
|
||||
const failed = [];
|
||||
let cancelled = false;
|
||||
// Cancel: the step that runs can't be stopped inside the worker, so the worker is restarted
|
||||
// (what was read so far is dropped; the tools then load what they need again)
|
||||
busyCancel = async () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
busyCancel = null;
|
||||
await restartWorker();
|
||||
};
|
||||
try {
|
||||
for (const [i, [name, channel, ...args]] of steps.entries()) {
|
||||
if (cancelled) break;
|
||||
setBusyNote(`Full load · step ${i + 1} of ${steps.length} · ${name}`);
|
||||
try {
|
||||
await call(channel, ...args);
|
||||
} catch (err) {
|
||||
if (err.code !== "cancelled") failed.push(`${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusyNote("");
|
||||
busyCancel = null;
|
||||
}
|
||||
if (cancelled) return toast("Full load cancelled. The tools load what they need when you open them.", "info");
|
||||
const secs = Math.round((performance.now() - t0) / 1000);
|
||||
if (failed.length) toast(`Full load done in ${secs} s; ${failed.length} step(s) failed — ${failed.join(" · ")}`, "bad");
|
||||
else toast(`Everything is loaded (${secs} s). The tools open without waiting now.`, "ok");
|
||||
}
|
||||
|
||||
// Pick an option, then Apply: Apply saves it, and "everything" also starts loading at once when a
|
||||
// folder is open (otherwise it runs the next time a folder is opened).
|
||||
function loadPanel() {
|
||||
const box = el("section", { class: "panel" });
|
||||
let picked = loadMode();
|
||||
const apply = () => {
|
||||
try {
|
||||
localStorage.setItem(LOAD_KEY, picked);
|
||||
} catch {
|
||||
// this visit only
|
||||
}
|
||||
render();
|
||||
if (picked === "full" && game.game()) preloadAll();
|
||||
else if (picked === "full") toast("Saved. Everything is loaded right after you open a game folder.", "ok");
|
||||
else toast("Saved. Each tool loads what it needs when you open it.", "ok");
|
||||
};
|
||||
const render = () => {
|
||||
const mode = loadMode();
|
||||
const option = (value, title, lines) =>
|
||||
el(
|
||||
"label",
|
||||
{ class: `load-option${picked === value ? " on" : ""}` },
|
||||
el("input", {
|
||||
type: "radio",
|
||||
name: "load-mode",
|
||||
checked: picked === value,
|
||||
onchange: () => {
|
||||
picked = value;
|
||||
render();
|
||||
},
|
||||
}),
|
||||
el("div", {}, el("b", { text: title + (mode === value ? " · current" : "") }), ...lines.map(([cls, text]) => el("p", { class: cls, text }))),
|
||||
);
|
||||
box.replaceChildren(
|
||||
el("h2", { text: "Loading" }),
|
||||
option("demand", "Load what a tool needs (recommended)", [
|
||||
["pro", "Opening the folder is quick, and only what you use is read."],
|
||||
["pro", "Uses little memory: fine on any PC."],
|
||||
["con", "Each tool waits the first time you open it (Heroes, Items, Quests, model index: from a few seconds to a minute on big clients)."],
|
||||
["risk", "Risk: low."],
|
||||
]),
|
||||
option("full", "Load everything after opening the folder", [
|
||||
["pro", "One wait up front, then every tool opens without waiting."],
|
||||
["pro", "Unreadable entries and wrong pack passwords show up at once, not halfway through your work."],
|
||||
["con", "Takes a few minutes on big clients, every time the folder is opened (nothing is kept between visits)."],
|
||||
["con", "Uses much more memory (hundreds of MB, more on big clients)."],
|
||||
["risk", "Risk: on a PC with little memory the tab can get slow, or the browser can close it. Not for phones."],
|
||||
]),
|
||||
el("div", { class: "actions" },
|
||||
el("button", { class: "btn gold", type: "button", text: picked === "full" && game.game() ? "Apply and load now" : "Apply", disabled: picked === mode, onclick: apply })),
|
||||
);
|
||||
};
|
||||
render();
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Built-in pack passwords (from the shared sources) and the visitor's own ones. */
|
||||
function passwordsPanel(afterSave) {
|
||||
const box = el("section", { class: "panel" });
|
||||
let custom = customKeys();
|
||||
const render = async () => {
|
||||
let builtin = [];
|
||||
try {
|
||||
builtin = (await listKeys()).filter((k) => k.builtin);
|
||||
} catch {
|
||||
// worker busy: the list shows next time
|
||||
}
|
||||
const rows = custom.map((k, i) => {
|
||||
const name = el("input", { type: "text", value: k.name || "", placeholder: "Name", spellcheck: "false" });
|
||||
const p0 = el("input", { type: "text", value: k.passwords?.[0] || "", placeholder: "Password (type 0)", spellcheck: "false", class: "mono" });
|
||||
const p1 = el("input", { type: "text", value: k.passwords?.[1] || "", placeholder: "Type 1 (optional)", spellcheck: "false", class: "mono" });
|
||||
const row = el(
|
||||
"div",
|
||||
{ class: "key-row" },
|
||||
name,
|
||||
p0,
|
||||
p1,
|
||||
el("button", { class: "btn ghost", type: "button", text: "Remove", onclick: () => save(rows.filter((_, j) => j !== i).map((r) => r.read())) }),
|
||||
);
|
||||
row.read = () => ({ id: k.id || `custom-${Date.now()}-${i}`, name: name.value.trim() || "Custom", passwords: [p0.value, p1.value || p0.value] });
|
||||
return row;
|
||||
});
|
||||
const save = async (list) => {
|
||||
try {
|
||||
custom = list.filter((k) => k.passwords && k.passwords[0]);
|
||||
await saveCustomKeys(custom);
|
||||
// tools that already read the packs keep their old view: start them again
|
||||
if (legacyStarted()) return location.reload();
|
||||
await render();
|
||||
afterSave();
|
||||
toast("Pack passwords saved.", "ok");
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
};
|
||||
box.replaceChildren(
|
||||
el("h2", { text: "Pack passwords" }),
|
||||
el("p", { class: "muted", text: `Tried in this order on every pack: your own passwords first, then the ${builtin.length} built-in ones from the shared sources (${builtin.map((k) => k.name).join(", ")}). Your passwords stay in this browser.` }),
|
||||
el("div", { class: "key-list" }, ...rows),
|
||||
el(
|
||||
"div",
|
||||
{ class: "actions" },
|
||||
el("button", { class: "btn ghost", type: "button", text: "Add password", onclick: () => ((custom = [...rows.map((r) => r.read()), { id: `custom-${Date.now()}`, name: "", passwords: ["", ""] }]), render()) }),
|
||||
rows.length ? el("button", { class: "btn gold", type: "button", text: "Save and read the packs again", onclick: () => save(rows.map((r) => r.read())) }) : null,
|
||||
),
|
||||
);
|
||||
};
|
||||
render();
|
||||
return box;
|
||||
}
|
||||
|
||||
function soonPage(main, tool) {
|
||||
main.replaceChildren(
|
||||
el(
|
||||
"section",
|
||||
{ class: "hero-card" },
|
||||
el("p", { class: "kicker", text: "Coming soon" }),
|
||||
el("h1", { text: tool.name }),
|
||||
el("p", { class: "lead", text: tool.desc }),
|
||||
el("p", { class: "muted", text: `This tool is being moved over from the desktop LS Tools (phase ${tool.phase}).` }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The desktop tools live in their own full-size area next to the page content.
|
||||
const toolArea = el("div", { class: "tool-area", id: "tool-area", hidden: true });
|
||||
document.querySelector(".layout").append(toolArea);
|
||||
|
||||
async function route() {
|
||||
const id = location.hash.replace(/^#\/?/, "").split("/")[0] || "home";
|
||||
const tool = byId(id) || byId("home");
|
||||
renderSide(tool.id);
|
||||
const main = document.getElementById("main");
|
||||
document.title = tool.id === "home" ? "LosaDev Tools" : `${tool.name} · LosaDev Tools`;
|
||||
// IOP Manager: its own web page (src/iop), not a desktop view
|
||||
if (tool.id === "iop") {
|
||||
main.hidden = true;
|
||||
toolArea.hidden = true;
|
||||
hideLegacy();
|
||||
hideCipher();
|
||||
return showIop(document.querySelector(".layout"), { toast });
|
||||
}
|
||||
hideIop();
|
||||
if (tool.id === "cipher") {
|
||||
main.hidden = true;
|
||||
toolArea.hidden = true;
|
||||
hideLegacy();
|
||||
return showCipher(document.querySelector(".layout"));
|
||||
}
|
||||
hideCipher();
|
||||
if (tool.view) {
|
||||
main.hidden = true;
|
||||
toolArea.hidden = false;
|
||||
try {
|
||||
await showView(toolArea, tool.view);
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
}
|
||||
return;
|
||||
}
|
||||
toolArea.hidden = true;
|
||||
hideLegacy();
|
||||
main.hidden = false;
|
||||
if (tool.id === "home") homePage(main);
|
||||
else soonPage(main, tool);
|
||||
main.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools' default source follows the opened game folder: packs, or unpacked files.
|
||||
* Tools that already started keep what they read, so another folder after that reloads the page
|
||||
* (the folder opens again by itself when the browser still allows it, else one click on Reopen).
|
||||
*/
|
||||
let firstFolder = true;
|
||||
game.onChange(async (g) => {
|
||||
if (!firstFolder && legacyStarted()) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
firstFolder = false;
|
||||
if (!g) return setGameFolder(null);
|
||||
let kind = "packs";
|
||||
try {
|
||||
kind = await call("game:kind");
|
||||
} catch {
|
||||
// keep packs
|
||||
}
|
||||
setGameFolder(g.name, kind);
|
||||
// "Load everything" mode: read every list and index now (the overlay shows each step)
|
||||
if (loadMode() === "full") preloadAll();
|
||||
// packs no known password opens: say so right away, wherever the visitor is
|
||||
if (kind === "packs") {
|
||||
try {
|
||||
const locked = (await call("packs:list")).filter((p) => !p.error && p.encrypted && !p.key);
|
||||
if (locked.length) toast(`No known password opens ${locked.map((p) => p.name).join(", ")}. Add the client's pack password on the Game Folder page.`, "bad");
|
||||
} catch {
|
||||
// the Game Folder page shows the error
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- busy overlay: a clear "loading" in the middle, and no clicks until it's done ----------
|
||||
{
|
||||
const label = el("span", { class: "busy-text", text: "Loading…" });
|
||||
// only jobs that can be stopped (Full load) show this button
|
||||
const cancelBtn = el("button", { class: "btn ghost busy-cancel", type: "button", hidden: true, text: "Cancel", onclick: () => busyCancel?.() });
|
||||
setInterval(() => (cancelBtn.hidden = !busyCancel), 250);
|
||||
const pct = el("b", { class: "busy-pct" });
|
||||
const fill = el("i", { class: "busy-fill" });
|
||||
const bar = el("div", { class: "busy-bar" }, fill);
|
||||
const overlay = el(
|
||||
"div",
|
||||
{ class: "busy", hidden: true, role: "status", "aria-live": "polite" },
|
||||
el("div", { class: "busy-card" }, el("div", { class: "busy-row" }, el("i", { class: "busy-spin" }), label, pct), bar, cancelBtn),
|
||||
);
|
||||
// swallow every click / key while busy
|
||||
for (const ev of ["pointerdown", "click", "dblclick", "wheel", "contextmenu"]) overlay.addEventListener(ev, (e) => (e.preventDefault(), e.stopPropagation()), { passive: false });
|
||||
const keys = (e) => {
|
||||
if (!overlay.hidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keys, true);
|
||||
document.body.append(overlay);
|
||||
onBusy((busy, text, percent, seconds) => {
|
||||
label.textContent = text || "Loading…";
|
||||
// a percent when the work can be counted (or estimated from last time), else the seconds
|
||||
pct.textContent = percent !== null && percent !== undefined ? `${percent}%` : seconds ? `${seconds} s` : "";
|
||||
bar.classList.toggle("unknown", percent === null || percent === undefined);
|
||||
fill.style.width = percent !== null && percent !== undefined ? `${percent}%` : "";
|
||||
overlay.hidden = !busy;
|
||||
document.body.classList.toggle("is-busy", busy);
|
||||
});
|
||||
}
|
||||
|
||||
game.onChange(renderStatus);
|
||||
window.addEventListener("hashchange", route);
|
||||
renderStatus();
|
||||
// The folder from the last visit opens by itself when the browser still allows it.
|
||||
// Custom pack passwords reach the worker first (the worker answers calls in order).
|
||||
sendCustomKeys();
|
||||
const quiet = import.meta.env.DEV && new URLSearchParams(location.search).has("devgame") ? Promise.resolve(null) : game.reopenQuietly();
|
||||
quiet.finally(route);
|
||||
|
||||
// Dev only: ?devgame=rs.iop,map.iop loads those files of LSTOOLS_TEST_DIR through the dev server,
|
||||
// so the pages can be tested without the native folder picker. Never part of a build.
|
||||
if (import.meta.env.DEV) {
|
||||
window.__call = call;
|
||||
const want = new URLSearchParams(location.search).get("devgame");
|
||||
if (want) {
|
||||
(async () => {
|
||||
const items = [];
|
||||
for (const name of want.split(",")) {
|
||||
const url = `/__testgame/${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url, { method: "HEAD" });
|
||||
if (!res.ok) throw new Error(`${name}: ${res.status}`);
|
||||
items.push({ path: name, url: new URL(url, location.href).href, size: Number(res.headers.get("content-length")) });
|
||||
}
|
||||
await call("game:mountUrls", "test-game", items);
|
||||
game.markMounted("test-game", () => call("game:mountUrls", "test-game", items));
|
||||
window.__devGameReady = true;
|
||||
route();
|
||||
})().catch((err) => toast(err.message, "bad"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/* LosaDev Tools: same look as losadev.wiki (deep navy, warm gold, soft white). Dark only. */
|
||||
:root {
|
||||
--bg: #0e1324;
|
||||
--bg-side: #111733;
|
||||
--panel: #1a2140;
|
||||
--panel-2: #121831;
|
||||
--line: #333a55;
|
||||
--gold: #d4a72c;
|
||||
--gold-hi: #f3dc9a;
|
||||
--gold-lo: #2a2310;
|
||||
--text: #e4e6ee;
|
||||
--text-2: #c2c7d6;
|
||||
--muted: #8d94ab;
|
||||
--ok: #7fd69a;
|
||||
--bad: #f08a7e;
|
||||
--warn: #f0c85a;
|
||||
--display: "Cinzel", "Times New Roman", serif;
|
||||
--body: "Inter Variable", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
--radius: 0.75rem;
|
||||
--top-h: 3.6rem;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.6 var(--body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--gold-hi); }
|
||||
button { font: inherit; }
|
||||
|
||||
/* One scrollbar style for every scrolling area (new pages and the legacy views alike).
|
||||
Elements that hide their scrollbar with `scrollbar-width: none` keep it hidden. */
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track, *::-webkit-scrollbar-corner { background: transparent; }
|
||||
*::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
|
||||
*::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: 6px; background: rgba(255, 255, 255, 0.14) padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background-color: rgba(212, 167, 44, 0.45); }
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.18) transparent; }
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
.top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
height: var(--top-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
background: #161c38;
|
||||
border-bottom: 1px solid rgba(212, 167, 44, 0.18);
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
color: var(--gold-hi);
|
||||
}
|
||||
.brand img { border-radius: 50%; }
|
||||
.brand em { font-style: normal; color: var(--text); font-weight: 600; }
|
||||
.top-status { flex: 1; display: flex; justify-content: center; }
|
||||
.status-chip {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
.status-chip.ok { color: var(--gold-hi); border-color: rgba(212, 167, 44, 0.45); background: var(--gold-lo); }
|
||||
.top-links { display: flex; gap: 0.5rem; }
|
||||
.top-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(212, 167, 44, 0.25);
|
||||
background: rgba(14, 19, 36, 0.6);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.top-links a svg { flex: none; color: var(--gold); }
|
||||
.top-links a:hover { border-color: rgba(243, 220, 154, 0.6); color: var(--gold-hi); background: rgba(212, 167, 44, 0.08); }
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.layout { display: grid; grid-template-columns: 15.5rem 1fr; min-height: calc(100vh - var(--top-h)); }
|
||||
.side {
|
||||
background: var(--bg-side);
|
||||
border-right: 1px solid rgba(212, 167, 44, 0.18);
|
||||
padding: 1.1rem 0.75rem 2rem;
|
||||
position: sticky;
|
||||
top: var(--top-h);
|
||||
height: calc(100vh - var(--top-h));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.side-group + .side-group { margin-top: 1.1rem; }
|
||||
.side-label {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
color: var(--gold-hi);
|
||||
padding: 0 0.5rem 0.35rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.side-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
margin: 0.05rem 0;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.side-link:hover { background: rgba(212, 167, 44, 0.08); color: var(--gold-hi); }
|
||||
.side-link.active {
|
||||
color: var(--gold-hi);
|
||||
background: linear-gradient(90deg, rgba(212, 167, 44, 0.22), rgba(212, 167, 44, 0.06));
|
||||
box-shadow: inset 3px 0 0 var(--gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-link.soon:not(.active) { color: var(--muted); }
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 0.4rem;
|
||||
color: var(--gold-hi);
|
||||
border: 1px solid rgba(212, 167, 44, 0.35);
|
||||
}
|
||||
|
||||
.main { padding: 2rem clamp(1rem, 3vw, 2.5rem) 4rem; max-width: 72rem; width: 100%; outline: none; }
|
||||
|
||||
/* ---------- cards ---------- */
|
||||
.hero-card, .panel {
|
||||
border-radius: 1.1rem;
|
||||
border: 1px solid rgba(212, 167, 44, 0.22);
|
||||
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
|
||||
padding: clamp(1.25rem, 3vw, 2rem);
|
||||
box-shadow: 0 10px 30px -18px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.panel { margin-top: 1.25rem; }
|
||||
.kicker {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
}
|
||||
h1, h2 { font-family: var(--display); color: var(--gold-hi); margin: 0 0 0.5rem; line-height: 1.15; }
|
||||
h1 { font-size: clamp(1.8rem, 4vw, 2.4rem); }
|
||||
h2 { font-size: 1.35rem; }
|
||||
.lead { color: var(--text-2); max-width: 44rem; margin: 0 0 1.25rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.ok { color: var(--ok); }
|
||||
.bad { color: var(--bad); }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.3rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn:focus-visible { outline: 2px solid var(--gold-hi); outline-offset: 3px; }
|
||||
.btn.gold {
|
||||
color: #1b1405;
|
||||
background: linear-gradient(180deg, #ffe6a0 0%, #e2b444 48%, #b9861a 100%);
|
||||
border: 1px solid #f7dd92;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 8px 24px -8px rgba(212, 167, 44, 0.55);
|
||||
}
|
||||
.btn.ghost { color: var(--text); background: rgba(27, 33, 56, 0.7); border: 1px solid rgba(243, 220, 154, 0.35); }
|
||||
.btn.ghost:hover { border-color: rgba(243, 220, 154, 0.85); background: rgba(212, 167, 44, 0.12); }
|
||||
.btn:disabled, .btn:disabled:hover { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
table.grid { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
|
||||
.grid th {
|
||||
text-align: left;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(212, 167, 44, 0.3);
|
||||
}
|
||||
.grid td { padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--line); }
|
||||
.grid tr:last-child td { border-bottom: 0; }
|
||||
.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
/* ---------- toasts ---------- */
|
||||
.toasts { position: fixed; right: 1rem; bottom: 1rem; display: grid; gap: 0.5rem; z-index: 50; }
|
||||
.toast {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(212, 167, 44, 0.35);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.8);
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
.toast.bad { border-color: rgba(240, 138, 126, 0.6); }
|
||||
.toast.out { opacity: 0; transform: translateY(6px); }
|
||||
|
||||
/* ---------- desktop tools area (the ported LS Tools views) ---------- */
|
||||
.layout > .tool-area { grid-column: 2; min-width: 0; height: calc(100vh - var(--top-h)); position: sticky; top: var(--top-h); overflow: hidden; }
|
||||
.tool-area > .lsx { display: flex; height: 100%; }
|
||||
.tool-area > .lsx > #app { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.tool-area > .lsx[hidden] { display: none; }
|
||||
|
||||
/* The desktop tools' design tokens, re-skinned to the site palette. Doubled class so it wins over
|
||||
the generated legacy.css (which puts the desktop's :root tokens on .lsx). */
|
||||
.lsx.lsx {
|
||||
--bg: #0e1324;
|
||||
--surface: #111733;
|
||||
--surface-2: #1a2140;
|
||||
--surface-3: #232b4d;
|
||||
--hover: rgba(212, 167, 44, 0.07);
|
||||
--line: rgba(212, 167, 44, 0.12);
|
||||
--line-2: rgba(212, 167, 44, 0.24);
|
||||
--text: #e4e6ee;
|
||||
--muted: #a3a9bd;
|
||||
--dim: #6b7391;
|
||||
--accent: #d4a72c;
|
||||
--accent-2: #f3dc9a;
|
||||
--accent-soft: rgba(212, 167, 44, 0.14);
|
||||
--brand: #d4a72c;
|
||||
--ok: #7fd69a;
|
||||
--bad: #f08a7e;
|
||||
--sel: rgba(212, 167, 44, 0.16);
|
||||
--radius: 0.6rem;
|
||||
--sans: "Inter Variable", system-ui, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.45 var(--sans);
|
||||
}
|
||||
.lsx.lsx h2, .lsx.lsx h3 { font-family: var(--display); color: var(--gold-hi); }
|
||||
|
||||
/* ---------- small screens ---------- */
|
||||
@media (max-width: 52rem) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.side { position: static; height: auto; display: flex; gap: 0.5rem; overflow-x: auto; padding: 0.6rem; border-right: 0; border-bottom: 1px solid rgba(212, 167, 44, 0.18); }
|
||||
.side-group { display: flex; gap: 0.25rem; align-items: center; flex: none; }
|
||||
.side-group + .side-group { margin-top: 0; }
|
||||
.side-label { display: none; }
|
||||
.side-link { white-space: nowrap; }
|
||||
.side-link .badge { display: none; }
|
||||
.top-status { display: none; }
|
||||
/* one column: the tool pages go under the tool bar (grid-column 2 made an extra implicit
|
||||
column and squeezed the tool bar into a ~20px strip on the left) */
|
||||
.layout > .tool-area { grid-column: 1; position: static; height: calc(100dvh - var(--top-h)); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; }
|
||||
}
|
||||
|
||||
/* ---------- pack passwords (Game Folder page) ---------- */
|
||||
.notice { margin: 0 0 0.9rem; padding: 0.7rem 0.9rem; border-radius: 8px; border: 1px solid; font-size: 0.9rem; }
|
||||
.notice.bad { border-color: rgba(240, 138, 126, 0.45); background: rgba(240, 138, 126, 0.08); color: #f6c1b9; }
|
||||
.notice.bad b { color: var(--bad); }
|
||||
.key-list { display: flex; flex-direction: column; gap: 0.5rem; margin: 0.6rem 0 0.9rem; }
|
||||
.key-list:empty { display: none; }
|
||||
.key-row { display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr 1fr auto; gap: 0.5rem; align-items: center; }
|
||||
.key-row input { height: 2.2rem; padding: 0 0.7rem; border-radius: 8px; border: 1px solid var(--line); background: var(--panel-2); color: var(--text); font: inherit; font-size: 0.88rem; }
|
||||
.key-row input.mono { font-family: var(--mono); }
|
||||
.key-row input:focus { outline: none; border-color: var(--gold); }
|
||||
@media (max-width: 52rem) { .key-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ---------- busy overlay (api.js onBusy): loading in the middle, clicks blocked ---------- */
|
||||
.busy { position: fixed; inset: var(--top-h) 0 0 0; z-index: 50; display: grid; place-items: center; background: rgba(8, 11, 22, 0.55); cursor: progress; animation: busyIn 0.15s ease both; }
|
||||
.busy[hidden] { display: none; }
|
||||
.busy-card { display: flex; align-items: center; gap: 0.8rem; padding: 0.95rem 1.4rem; border-radius: 10px; border: 1px solid rgba(212, 167, 44, 0.45); background: #161c38; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); color: var(--gold-hi); font-weight: 600; font-size: 0.98rem; }
|
||||
.busy-spin { width: 1.15rem; height: 1.15rem; border-radius: 50%; border: 2.5px solid rgba(212, 167, 44, 0.25); border-top-color: var(--gold); animation: busySpin 0.8s linear infinite; }
|
||||
body.is-busy { cursor: progress; }
|
||||
@keyframes busySpin { to { transform: rotate(360deg); } }
|
||||
@keyframes busyIn { from { opacity: 0; } }
|
||||
.busy-card { flex-direction: column; align-items: stretch; min-width: 18rem; }
|
||||
.busy-row { display: flex; align-items: center; gap: 0.8rem; }
|
||||
.busy-text { flex: 1; }
|
||||
.busy-pct { font-variant-numeric: tabular-nums; color: var(--text); min-width: 3ch; text-align: right; }
|
||||
.busy-bar { position: relative; height: 4px; border-radius: 4px; background: rgba(212, 167, 44, 0.15); overflow: hidden; }
|
||||
.busy-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--gold); transition: width 0.2s linear; }
|
||||
.busy-bar.unknown .busy-fill { width: 30%; animation: busySlide 1.1s ease-in-out infinite; }
|
||||
@keyframes busySlide { from { left: -30%; } to { left: 100%; } }
|
||||
|
||||
/* ---------- site pages (Game Folder…) in the tools' layout: head row, cards ---------- */
|
||||
.page-head { display: flex; align-items: flex-start; gap: 1rem; padding: 12px 2px 14px; }
|
||||
.page-title { flex: none; max-width: 46ch; }
|
||||
.page-title h1 { margin: 0; font: 600 1.4rem/1.15 var(--display); letter-spacing: 0.02em; color: var(--gold-hi); }
|
||||
.page-title p { margin: 2px 0 0; color: var(--muted); font-size: 12.5px; line-height: 1.4; }
|
||||
.page-actions { flex: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin: 0; padding-top: 4px; }
|
||||
.page-actions .btn { padding: 0 14px; height: 34px; border-radius: 8px; font-size: 0.88rem; }
|
||||
.page-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||
.page-grid > .panel { margin: 0; border-radius: 16px; border: 1px solid rgba(212, 167, 44, 0.12); background: #111733; padding: 14px 16px; }
|
||||
.page-grid > .panel.wide { grid-column: 1 / -1; }
|
||||
.page-grid > .panel h2 { margin: 0 0 0.6rem; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--body); font-weight: 700; color: var(--gold); }
|
||||
@media (max-width: 64rem) { .page-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.load-option { display: flex; gap: 0.7rem; padding: 0.7rem 0.8rem; margin-bottom: 0.5rem; border-radius: 10px; border: 1px solid rgba(212, 167, 44, 0.14); cursor: pointer; }
|
||||
.load-option.on { border-color: rgba(212, 167, 44, 0.55); background: rgba(212, 167, 44, 0.06); }
|
||||
.load-option input { margin-top: 0.25rem; accent-color: var(--gold); }
|
||||
.load-option b { display: block; margin-bottom: 0.25rem; color: var(--text); }
|
||||
.load-option p { margin: 0.1rem 0; font-size: 0.84rem; line-height: 1.4; padding-left: 1.1rem; position: relative; color: var(--text-2); }
|
||||
.load-option p::before { position: absolute; left: 0; font-weight: 700; }
|
||||
.load-option p.pro::before { content: "+"; color: var(--ok); }
|
||||
.load-option p.con::before { content: "−"; color: var(--warn); }
|
||||
.load-option p.risk { color: #f6c1b9; }
|
||||
.load-option p.risk::before { content: "!"; color: var(--bad); }
|
||||
#main:has(> .page-head) { max-width: none; width: auto; margin: 0; padding: 0 16px 16px; }
|
||||
.busy-cancel { align-self: flex-end; height: 30px; padding: 0 14px; font-size: 0.85rem; cursor: pointer; }
|
||||
.busy-cancel[hidden] { display: none; }
|
||||
@@ -0,0 +1,47 @@
|
||||
// The tools shown in the sidebar. `phase` = the PLAN.md phase that ports it; a tool without
|
||||
// `page` is listed as coming soon.
|
||||
export const GROUPS = [
|
||||
{
|
||||
label: "Start",
|
||||
tools: [
|
||||
{ id: "home", name: "Game Folder", desc: "Open your client folder and check its packs." },
|
||||
{ id: "iop", name: "IOP Manager", desc: "Browse, preview and extract pack entries; add, replace, rename, delete and optimize into a downloaded copy.", phase: "8b", page: true },
|
||||
{ id: "cipher", name: "Encode / Decode", desc: "Encrypt and decrypt server IDs, IOP passwords and DB agent passwords.", page: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Interface",
|
||||
tools: [
|
||||
{ id: "imagesets", name: "UI Image Sets", desc: "Edit uiimageset.xml boxes on their textures.", phase: 1 },
|
||||
{ id: "windows", name: "UI Windows", desc: "Preview and edit window XML layouts.", phase: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "3D",
|
||||
tools: [
|
||||
{ id: "models", name: "Models", desc: "View meshes, skeletons, animations and materials.", phase: 2 },
|
||||
{ id: "maps", name: "Maps", desc: "View maps with their collision, markers and objects.", phase: 3 },
|
||||
{ id: "effects", name: "Effects", desc: "Particle and model effects.", phase: 4 },
|
||||
{ id: "fxlab", name: "FX Lab", desc: "Build and convert effects.", phase: 4 },
|
||||
{ id: "textures", name: "Textures", desc: "Browse and convert DDS / TGA textures.", phase: 8 },
|
||||
{ id: "anim", name: "Anim Studio", desc: "Inspect and retarget animations.", phase: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Game Data",
|
||||
tools: [
|
||||
{ id: "items", name: "Items", desc: "Items and equipment with their names.", phase: 5 },
|
||||
{ id: "costumes", name: "Costumes", desc: "Costume sets and parts.", phase: 5 },
|
||||
{ id: "heroes", name: "Heroes", desc: "Hero definitions and exports.", phase: 5 },
|
||||
{ id: "quests", name: "Quests", desc: "Quests, rewards and missions.", phase: 6 },
|
||||
{ id: "tables", name: "Tables", desc: "DAT tables with Excel import and export.", phase: 7 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Desktop view id of each ported tool (public/legacy/views/<view>.html).
|
||||
const VIEWS = { imagesets: "ui", windows: "wnd", models: "mdl", anim: "ans", costumes: "cos", effects: "fx", fxlab: "fxlab", heroes: "hero", textures: "tex", maps: "map", items: "items", quests: "quests", tables: "tables" };
|
||||
for (const g of GROUPS) for (const t of g.tools) if (VIEWS[t.id]) t.view = VIEWS[t.id];
|
||||
|
||||
export const ALL = GROUPS.flatMap((g) => g.tools);
|
||||
export const byId = (id) => ALL.find((t) => t.id === id);
|
||||
@@ -0,0 +1,592 @@
|
||||
// IOP Manager (web, PLAN phase 8b): browse, preview, extract, verify; add / replace / rename /
|
||||
// delete / optimize on a copy that is offered as a download (never written into the game folder).
|
||||
//
|
||||
// Packs are opened read-only (game folder packs from worker.js, or files the visitor picks / drops,
|
||||
// recognised by content). Changes live in a WorkPack: the original pack's entries plus records
|
||||
// appended in memory, with the client's rules kept (last entry of a name wins, comment "1" + .ini =
|
||||
// XOR-scrambled content, new data encrypted with the pack's type-0 password). "Download pack"
|
||||
// builds the new file as a Blob from slices of the original file + the new records + a fresh
|
||||
// central directory, so even a 1 GB pack is not copied into memory.
|
||||
import * as vfs from "/src/worker/vfs.js";
|
||||
import { inflateSync, zipSync } from "fflate";
|
||||
import { Pack, ZipCrypto, crc32, keyOf } from "./lib/core/iop.js";
|
||||
import tools from "./lib/core/tools.js";
|
||||
import { detectKey, allKeys } from "./keys.js";
|
||||
|
||||
const SIG_LOCAL = 0x04034b50;
|
||||
const SIG_CENTRAL = 0x02014b50;
|
||||
const SIG_END = 0x06054b50;
|
||||
const SIG_DESC = 0x08074b50;
|
||||
const MAX_ENTRIES = 65535;
|
||||
const IMAGE_EXT = /\.(dds|png|tga|jpe?g|bmp|gif)$/i;
|
||||
const MAX_ZIP = 1.5 * 1024 ** 3; // a zip is built in memory: bigger selections go folder by folder
|
||||
|
||||
const kOf = (e) => e._k || (e._k = keyOf(e.name));
|
||||
const keysSig = () => allKeys().map((k) => `${k.id}:${k.passwords.join("|")}`).join(",");
|
||||
const cp949 = new TextDecoder("euc-kr");
|
||||
/** Entry names are latin1 strings of the stored bytes; Korean ones read as CP949. */
|
||||
const displayName = (n) => (/[\x80-\xff]/.test(n) ? cp949.decode(Buffer.from(n, "latin1")) : n);
|
||||
|
||||
function centralRecord(e) {
|
||||
const b = Buffer.alloc(46);
|
||||
b.writeUInt32LE(SIG_CENTRAL, 0);
|
||||
b.writeUInt16LE(e.nameBuf.length, 4);
|
||||
b.writeUInt16LE(e.flags, 8);
|
||||
b.writeUInt16LE(e.method, 10);
|
||||
b.writeUInt16LE(e.time, 12);
|
||||
b.writeUInt16LE(e.date, 14);
|
||||
b.writeUInt32LE(e.crc, 16);
|
||||
b.writeUInt32LE(e.csize, 20);
|
||||
b.writeUInt32LE(e.usize, 24);
|
||||
b.writeUInt16LE(0x0014, 28);
|
||||
b.writeUInt16LE(0x0014, 30);
|
||||
b.writeUInt16LE(e.comment.length, 32);
|
||||
b.writeUInt32LE(0, 42);
|
||||
return Buffer.concat([b, e.nameBuf, e.comment]);
|
||||
}
|
||||
|
||||
function endRecord(count, cdSize, cdOffset, comment) {
|
||||
const b = Buffer.alloc(22);
|
||||
b.writeUInt32LE(SIG_END, 0);
|
||||
b.writeUInt16LE(count, 8);
|
||||
b.writeUInt16LE(count, 10);
|
||||
b.writeUInt32LE(cdSize, 12);
|
||||
b.writeUInt32LE(cdOffset, 16);
|
||||
b.writeUInt16LE(comment.length, 20);
|
||||
return Buffer.concat([b, comment]);
|
||||
}
|
||||
|
||||
/** Central record with another offset (base records are subarrays of the read directory). */
|
||||
function withOffset(raw, offset) {
|
||||
const r = Buffer.from(raw);
|
||||
r.writeUInt32LE(offset >>> 0, 42);
|
||||
return r;
|
||||
}
|
||||
|
||||
const estLen = (e) => 30 + e.nameLen + e.csize + (e.flags & 8 ? 16 : 0);
|
||||
|
||||
/**
|
||||
* A pack being edited: the read-only original plus records appended in memory. Same entry shape
|
||||
* and appendRecord signature as core/iop.js Pack, so core/tools.js addFile works on it.
|
||||
*/
|
||||
class WorkPack {
|
||||
constructor(base) {
|
||||
this.base = base;
|
||||
this.entries = [...base.entries]; // every copy, file order; new records at the end
|
||||
this.index = new Map();
|
||||
for (const e of this.entries) this.index.set(kOf(e), e);
|
||||
this.mem = []; // appended entries (entry.mem = { rec, bodyStart })
|
||||
this.optimize = false;
|
||||
this.changes = { added: 0, replaced: 0, renamed: 0, removed: 0 };
|
||||
}
|
||||
|
||||
get dirty() {
|
||||
return this.mem.length > 0 || this.changes.removed > 0 || this.optimize;
|
||||
}
|
||||
|
||||
lookup(name) {
|
||||
return this.index.get(keyOf(name));
|
||||
}
|
||||
|
||||
liveEntries() {
|
||||
return this.entries.filter((e) => this.index.get(kOf(e)) === e);
|
||||
}
|
||||
|
||||
appendRecord(name, { flags, method, time, date, body }, { crc, usize, comment }) {
|
||||
const nameBuf = Buffer.from(name.replace(/\\/g, "/"), "latin1");
|
||||
const key = keyOf(nameBuf.toString("latin1"));
|
||||
if (!this.index.has(key) && this.index.size >= MAX_ENTRIES) throw new Error("The pack would have more than 65,535 entries.");
|
||||
const desc = (flags & 8) !== 0;
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(SIG_LOCAL, 0);
|
||||
local.writeUInt16LE(nameBuf.length, 4);
|
||||
local.writeUInt16LE(0, 6);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(time, 10);
|
||||
local.writeUInt16LE(date, 12);
|
||||
if (!desc) {
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(usize, 22);
|
||||
}
|
||||
local.writeUInt16LE(0x0014, 26);
|
||||
local.writeUInt16LE(flags, 28);
|
||||
const parts = [local, nameBuf, body];
|
||||
if (desc) {
|
||||
const d = Buffer.alloc(16);
|
||||
d.writeUInt32LE(SIG_DESC, 0);
|
||||
d.writeUInt32LE(crc, 4);
|
||||
d.writeUInt32LE(body.length, 8);
|
||||
d.writeUInt32LE(usize, 12);
|
||||
parts.push(d);
|
||||
}
|
||||
const entry = {
|
||||
name: nameBuf.toString("latin1"),
|
||||
nameBuf,
|
||||
nameLen: nameBuf.length,
|
||||
flags,
|
||||
method,
|
||||
time,
|
||||
date,
|
||||
crc: crc >>> 0,
|
||||
csize: body.length,
|
||||
usize,
|
||||
comment: Buffer.from(comment || []),
|
||||
offset: -1, // set when the pack is built
|
||||
mem: { rec: Buffer.concat(parts), bodyStart: 30 + nameBuf.length },
|
||||
};
|
||||
entry.raw = centralRecord(entry);
|
||||
entry._k = key;
|
||||
this.entries.push(entry);
|
||||
this.mem.push(entry);
|
||||
this.index.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Removes every copy of these names; returns how many names were in the pack. */
|
||||
remove(names) {
|
||||
const keys = new Set();
|
||||
for (const n of names) if (this.index.has(keyOf(n))) keys.add(keyOf(n));
|
||||
if (!keys.size) return 0;
|
||||
for (const k of keys) this.index.delete(k);
|
||||
this.entries = this.entries.filter((e) => !keys.has(kOf(e)));
|
||||
this.mem = this.mem.filter((e) => !keys.has(kOf(e)));
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes of the original pack at pos. Reads go through a 4 MB window: one FileReaderSync call per
|
||||
* entry pair was slow on big Blobs (verify of a 400 MB downloaded pack: 89 s). A new window is a
|
||||
* new buffer, so views handed out earlier stay valid.
|
||||
*/
|
||||
readAt(pos, len) {
|
||||
const W = 4 << 20;
|
||||
if (len > W) {
|
||||
const b = Buffer.alloc(len);
|
||||
if (vfs.readSync(this.base.fd, b, 0, len, pos) !== len) throw new Error("Unexpected end of the pack");
|
||||
return b;
|
||||
}
|
||||
if (!this.win || pos < this.winPos || pos + len > this.winPos + this.win.length) {
|
||||
const b = Buffer.alloc(W);
|
||||
const n = vfs.readSync(this.base.fd, b, 0, W, pos);
|
||||
this.win = b.subarray(0, n);
|
||||
this.winPos = pos;
|
||||
if (n < len) throw new Error("Unexpected end of the pack");
|
||||
}
|
||||
return this.win.subarray(pos - this.winPos, pos - this.winPos + len);
|
||||
}
|
||||
|
||||
/** Stored bytes of an entry (still compressed / encrypted). */
|
||||
rawBody(e) {
|
||||
if (e.mem) return e.mem.rec.subarray(e.mem.bodyStart, e.mem.bodyStart + e.csize);
|
||||
const head = this.readAt(e.offset, 30);
|
||||
if (head.readUInt32LE(0) !== SIG_LOCAL) throw new Error(`bad local header for ${e.name}`);
|
||||
return this.readAt(e.offset + 30 + head.readUInt16LE(4) + head.readUInt16LE(6), e.csize);
|
||||
}
|
||||
|
||||
/** Decrypt, inflate and check one entry. */
|
||||
read(e, password) {
|
||||
let body = this.rawBody(e);
|
||||
if (e.flags & 1) {
|
||||
if (!password) throw new Error("encrypted, no password");
|
||||
const zc = new ZipCrypto(password);
|
||||
const h = zc.decrypt(body.subarray(0, 12));
|
||||
if (h[11] !== (e.flags & 8 ? e.time >>> 8 : e.crc >>> 24)) throw new Error("Wrong password");
|
||||
body = zc.decrypt(body.subarray(12));
|
||||
}
|
||||
let data;
|
||||
if (e.method === 8) data = e.usize ? inflateSync(body, { out: new Uint8Array(e.usize) }) : new Uint8Array(0);
|
||||
else if (e.method === 0) data = e.flags & 1 ? body : Buffer.from(body); // own copy, not a view of the read window
|
||||
else throw new Error(`Unsupported method ${e.method}`);
|
||||
if (data.length !== e.usize) throw new Error("Size mismatch");
|
||||
if (crc32(data) !== e.crc >>> 0) throw new Error("CRC mismatch");
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.length);
|
||||
}
|
||||
|
||||
/** Sizes: data area, used by live entries, unused (old copies, removed files). */
|
||||
stats() {
|
||||
const live = this.liveEntries();
|
||||
const used = live.reduce((n, e) => n + estLen(e), 0);
|
||||
const memLive = live.filter((e) => e.mem).reduce((n, e) => n + e.mem.rec.length, 0);
|
||||
const data = this.base.cdOffset + memLive;
|
||||
const cd = live.reduce((n, e) => n + e.raw.length, 0) + 22 + this.base.comment.length;
|
||||
return { live, used, unused: Math.max(0, this.base.cdOffset - live.filter((e) => !e.mem).reduce((n, e) => n + estLen(e), 0)), size: data + cd, optimizedSize: used + cd };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* registerIop(handlers, ctx) — ctx from worker.js: { openGamePacks(), packs (Map lower name →
|
||||
* { name, pack, key, encrypted }), progress(done, total), GAME }.
|
||||
*/
|
||||
export function registerIop(handlers, ctx) {
|
||||
const sessions = new Map(); // id → { id, origin, name, path, pack, key, encrypted, keys, work }
|
||||
let fileSeq = 0;
|
||||
const blobs = new Map(); // dev remote url → Promise<Blob>
|
||||
|
||||
const gameMounted = () => vfs.mountNames().some((m) => m.toLowerCase() === "game");
|
||||
|
||||
/** Game folder packs as sessions (rs.iop first, then map*.iop, then the rest). */
|
||||
function syncGame() {
|
||||
const seen = new Set();
|
||||
if (gameMounted()) {
|
||||
ctx.openGamePacks();
|
||||
for (const rec of ctx.packs.values()) {
|
||||
const id = `g:${rec.name.toLowerCase()}`;
|
||||
seen.add(id);
|
||||
const s = sessions.get(id);
|
||||
if (s && s.pack === rec.pack) continue; // same open pack: keep its pending changes
|
||||
sessions.set(id, { id, origin: "game", name: rec.name, path: `${ctx.GAME}/${rec.name}`, pack: rec.pack, key: rec.key, encrypted: rec.encrypted, keys: keysSig(), work: null });
|
||||
}
|
||||
}
|
||||
for (const id of [...sessions.keys()]) if (id.startsWith("g:") && !seen.has(id)) sessions.delete(id);
|
||||
}
|
||||
|
||||
function session(id) {
|
||||
if (String(id).startsWith("g:")) syncGame();
|
||||
const s = sessions.get(id);
|
||||
if (!s) throw new Error("That pack is not open any more.");
|
||||
// pack passwords changed (Game Folder page): detect again
|
||||
if (s.origin === "file" && s.keys !== keysSig()) Object.assign(s, detectKey(s.pack), { keys: keysSig() });
|
||||
return s;
|
||||
}
|
||||
|
||||
const work = (s) => s.work || (s.work = new WorkPack(s.pack));
|
||||
|
||||
/** Passwords to try for an encrypted entry: the detected one first, then its other type. */
|
||||
const passwords = (s) => (s.key ? [...new Set(s.key.passwords)] : []);
|
||||
|
||||
/** The password new entries are encrypted with (the one that opens the pack). */
|
||||
function writePassword(s) {
|
||||
if (!s.encrypted) return null;
|
||||
if (!s.key) throw new Error("No known password opens this pack, so it can't be changed. Add its password on the Game Folder page.");
|
||||
return s.key.passwords[0];
|
||||
}
|
||||
|
||||
/** Plain content of an entry; also says which password opened it. */
|
||||
function readPlain(s, e, { xorIni = true } = {}) {
|
||||
const w = work(s);
|
||||
let data;
|
||||
let used = null;
|
||||
if (!(e.flags & 1)) data = w.read(e, null);
|
||||
else {
|
||||
let err = null;
|
||||
for (const pw of passwords(s)) {
|
||||
try {
|
||||
data = w.read(e, pw);
|
||||
used = pw;
|
||||
break;
|
||||
} catch (x) {
|
||||
err = x;
|
||||
}
|
||||
}
|
||||
if (!data) throw err || new Error("No known password opens this pack");
|
||||
}
|
||||
if (xorIni && tools.isXorIni(e.name, e.comment)) data = tools.iniXor(data);
|
||||
return { data, password: used };
|
||||
}
|
||||
|
||||
function info(s) {
|
||||
const w = work(s);
|
||||
const st = w.stats();
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
origin: s.origin,
|
||||
sourceSize: s.pack.size,
|
||||
size: w.dirty ? (w.optimize ? st.optimizedSize : st.size) : s.pack.size,
|
||||
entries: w.entries.length,
|
||||
live: st.live.length,
|
||||
overwritten: w.entries.length - st.live.length,
|
||||
unused: st.unused,
|
||||
optimizedSize: st.optimizedSize,
|
||||
key: s.key ? s.key.name : null,
|
||||
encrypted: !!s.encrypted,
|
||||
keyOk: !s.encrypted || !!s.key,
|
||||
dirty: w.dirty,
|
||||
optimize: w.optimize,
|
||||
changes: { ...w.changes },
|
||||
};
|
||||
}
|
||||
|
||||
function entryOf(s, name) {
|
||||
const e = work(s).lookup(name);
|
||||
if (!e) throw new Error(`${displayName(name)} isn't in the pack.`);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** A Blob of the original pack file, for slices (dev remote files are fetched once). */
|
||||
async function baseBlob(s) {
|
||||
const f = s.file || vfs.fileOf(s.path);
|
||||
if (!f.remote) return f;
|
||||
if (!blobs.has(f.url)) blobs.set(f.url, fetch(f.url).then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`${s.name}: HTTP ${r.status}`)))));
|
||||
return blobs.get(f.url);
|
||||
}
|
||||
|
||||
const readFile = (file) => Buffer.from(new FileReaderSync().readAsArrayBuffer(file));
|
||||
|
||||
Object.assign(handlers, {
|
||||
/** Every open pack: the game folder's first, then the opened files. */
|
||||
"iopw:sessions"() {
|
||||
syncGame();
|
||||
const order = (s) => (s.origin === "game" ? (s.name.toLowerCase() === "rs.iop" ? 0 : /^map\d*\.iop$/i.test(s.name) ? 1 : 2) : 3);
|
||||
return [...sessions.values()].sort((a, b) => order(a) - order(b) || (a.origin === "file" ? 0 : a.name.localeCompare(b.name))).map(info);
|
||||
},
|
||||
|
||||
/** Opens picked / dropped files as packs (any extension: a pack is what parses as one). */
|
||||
"iopw:open"(items) {
|
||||
const opened = [];
|
||||
const failed = [];
|
||||
for (const { name, file } of items || []) {
|
||||
const n = ++fileSeq;
|
||||
const mount = `iop-open-${n}`;
|
||||
const safe = String(name || file.name).replace(/[\\/]/g, "_");
|
||||
try {
|
||||
if (file.size < 22) throw new Error("too small to be a pack");
|
||||
vfs.mountFiles(mount, [{ path: safe, file }]);
|
||||
const pack = Pack.open(`/${mount}/${safe}`, { readOnly: true });
|
||||
if (pack.entries.length) pack.recordLength(pack.entries[0]); // a real local header where the directory says
|
||||
const id = `f:${n}`;
|
||||
sessions.set(id, { id, origin: "file", name: safe, path: `/${mount}/${safe}`, file, pack, ...detectKey(pack), keys: keysSig(), work: null });
|
||||
opened.push(info(sessions.get(id)));
|
||||
} catch (err) {
|
||||
if (err instanceof vfs.NeedResolve) throw err;
|
||||
vfs.unmount(mount);
|
||||
failed.push({ name: safe, error: /end record|central directory|local header|too small/i.test(err.message) ? "Not a pack (no ZIP directory found)." : err.message });
|
||||
}
|
||||
}
|
||||
return { opened, failed };
|
||||
},
|
||||
|
||||
/** Closes an opened file (game folder packs stay; their pending changes are dropped). */
|
||||
"iopw:close"(id) {
|
||||
const s = sessions.get(id);
|
||||
if (!s) return true;
|
||||
if (s.origin === "file") {
|
||||
s.pack.close();
|
||||
vfs.unmount(s.path.split("/")[1]);
|
||||
sessions.delete(id);
|
||||
} else s.work = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
"iopw:info": (id) => info(session(id)),
|
||||
|
||||
/** Rows: [name, size, packed, flags, comment, older copies, changed (1 = new in this session)]. */
|
||||
"iopw:entries"(id) {
|
||||
const s = session(id);
|
||||
const w = work(s);
|
||||
const counts = new Map();
|
||||
for (const e of w.entries) counts.set(kOf(e), (counts.get(kOf(e)) || 0) + 1);
|
||||
return {
|
||||
info: info(s),
|
||||
rows: w.liveEntries().map((e) => [displayName(e.name), e.usize, e.csize, e.flags, e.comment.toString("latin1"), counts.get(kOf(e)) - 1, e.mem ? 1 : 0, e.name]),
|
||||
};
|
||||
},
|
||||
|
||||
/** One entry for the preview pane: text (first 512 KB), image bytes, or the first 16 KB for hex. */
|
||||
"iopw:preview"(id, name) {
|
||||
const s = session(id);
|
||||
const e = entryOf(s, name);
|
||||
const meta = { name: displayName(e.name), size: e.usize, packed: e.csize, crc: e.crc >>> 0, date: e.date, time: e.time, flags: e.flags, comment: e.comment.toString("latin1"), changed: !!e.mem, xorIni: tools.isXorIni(e.name, e.comment) };
|
||||
const { data } = readPlain(s, e);
|
||||
const ext = (/\.([^./\\]+)$/.exec(e.name) || [])[1]?.toLowerCase() || "";
|
||||
if (IMAGE_EXT.test(e.name) && data.length <= 64 * 1024 * 1024) return { ...meta, kind: "image", ext, bytes: new Uint8Array(data) };
|
||||
const head = data.subarray(0, 4096);
|
||||
const utf16 = head.length >= 2 && ((head[0] === 0xff && head[1] === 0xfe) || (head[0] === 0xfe && head[1] === 0xff));
|
||||
if (utf16 || !head.includes(0)) return { ...meta, kind: "text", bytes: new Uint8Array(data.subarray(0, 512 * 1024)), truncated: data.length > 512 * 1024 };
|
||||
return { ...meta, kind: "hex", bytes: new Uint8Array(data.subarray(0, 16 * 1024)), truncated: data.length > 16 * 1024 };
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts entries: one → that file, several → a zip keeping their folders.
|
||||
* Returns { name, blob, extracted, failed: [{ name, error }] }.
|
||||
*/
|
||||
"iopw:extract"(id, names, zipName) {
|
||||
const s = session(id);
|
||||
const list = [...new Set(names || [])].map((n) => work(s).lookup(n)).filter(Boolean);
|
||||
if (!list.length) throw new Error("Nothing to extract.");
|
||||
if (list.length === 1) {
|
||||
const e = list[0];
|
||||
return { name: displayName(e.name).split(/[\\/]/).pop(), blob: new Blob([readPlain(s, e).data]), extracted: 1, failed: [] };
|
||||
}
|
||||
const total = list.reduce((n, e) => n + e.usize, 0);
|
||||
if (total > MAX_ZIP) throw new Error(`That is ${(total / 1024 ** 3).toFixed(1)} GB unpacked; extract it a few folders at a time (a zip is built in memory, up to 1.5 GB).`);
|
||||
const files = {};
|
||||
const failed = [];
|
||||
list.forEach((e, i) => {
|
||||
ctx.progress(i, list.length);
|
||||
const path = displayName(e.name).replace(/\\/g, "/").split("/").filter((p) => p && p !== "." && p !== "..").join("/");
|
||||
try {
|
||||
files[path] = [new Uint8Array(readPlain(s, e).data), { level: 1 }];
|
||||
} catch (err) {
|
||||
failed.push({ name: displayName(e.name), error: err.message });
|
||||
}
|
||||
});
|
||||
ctx.progress(list.length, list.length);
|
||||
const zip = zipSync(files);
|
||||
return { name: zipName || `${s.name.replace(/\.[^.]+$/, "")}.zip`, blob: new Blob([zip], { type: "application/zip" }), extracted: list.length - failed.length, failed };
|
||||
},
|
||||
|
||||
/** Reads every live entry like the client (decrypt, inflate, CRC). */
|
||||
"iopw:verify"(id) {
|
||||
const s = session(id);
|
||||
const live = work(s).liveEntries().filter((e) => !e.name.endsWith("/"));
|
||||
const bad = [];
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
if (i % 100 === 0) ctx.progress(i, live.length);
|
||||
try {
|
||||
readPlain(s, live[i], { xorIni: false });
|
||||
} catch (err) {
|
||||
bad.push({ name: displayName(live[i].name), error: err.message });
|
||||
}
|
||||
}
|
||||
ctx.progress(live.length, live.length);
|
||||
return { checked: live.length, bad };
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds files: items = [{ name (path inside the pack), file }]. comment: "" | "1" | null
|
||||
* (null = keep the comment of the entry it replaces). The newest copy of a name wins.
|
||||
*/
|
||||
"iopw:add"(id, items, comment = null) {
|
||||
const s = session(id);
|
||||
const w = work(s);
|
||||
const pw = writePassword(s);
|
||||
let added = 0;
|
||||
let replaced = 0;
|
||||
(items || []).forEach((it, i) => {
|
||||
ctx.progress(i, items.length);
|
||||
const name = String(it.name).replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!name || name.endsWith("/")) return;
|
||||
const old = w.lookup(name);
|
||||
const c = comment === null || comment === undefined ? (old ? old.comment.toString("latin1") : "") : String(comment);
|
||||
tools.addFile(w, old ? old.name : name, readFile(it.file), { password: pw, comment: c, mtime: new Date(it.file.lastModified || Date.now()) });
|
||||
if (old) replaced++;
|
||||
else added++;
|
||||
});
|
||||
w.changes.added += added;
|
||||
w.changes.replaced += replaced;
|
||||
return { added, replaced, info: info(s) };
|
||||
},
|
||||
|
||||
/** Replaces one entry's content (its name and comment kept, .ini XOR applied again). */
|
||||
"iopw:replace"(id, name, file) {
|
||||
const s = session(id);
|
||||
const w = work(s);
|
||||
const e = entryOf(s, name);
|
||||
let pw = writePassword(s);
|
||||
// an entry opened by the other password type is written back with that one (Indonesia packs)
|
||||
if (e.flags & 1) {
|
||||
try {
|
||||
pw = readPlain(s, e).password || pw;
|
||||
} catch {
|
||||
// unreadable old copy: the pack's password
|
||||
}
|
||||
}
|
||||
tools.addFile(w, e.name, readFile(file), { password: pw, comment: e.comment.toString("latin1"), mtime: new Date(file.lastModified || Date.now()) });
|
||||
w.changes.replaced++;
|
||||
return info(s);
|
||||
},
|
||||
|
||||
/** Renames an entry: its stored bytes go under the new name (nothing is re-compressed). */
|
||||
"iopw:rename"(id, from, to) {
|
||||
const s = session(id);
|
||||
const w = work(s);
|
||||
const e = entryOf(s, from);
|
||||
const target = String(to || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!target || target.endsWith("/")) throw new Error("Enter a file path inside the pack.");
|
||||
if (w.lookup(target) && keyOf(target) !== kOf(e)) throw new Error("An entry with that name already exists.");
|
||||
const body = Buffer.from(w.rawBody(e)); // a copy: rawBody may be a view of the read window
|
||||
w.appendRecord(target, { flags: e.flags, method: e.method, time: e.time, date: e.date, body }, e);
|
||||
if (keyOf(target) !== kOf(e)) w.remove([e.name]);
|
||||
w.changes.renamed++;
|
||||
return info(s);
|
||||
},
|
||||
|
||||
/** Removes every copy of these names. */
|
||||
"iopw:remove"(id, names) {
|
||||
const s = session(id);
|
||||
const w = work(s);
|
||||
const removed = w.remove(names || []);
|
||||
w.changes.removed += removed;
|
||||
return { removed, info: info(s) };
|
||||
},
|
||||
|
||||
/** What Optimize would drop: old copies and their space. */
|
||||
"iopw:optimizeInfo"(id) {
|
||||
const i = info(session(id));
|
||||
return { overwritten: i.overwritten, unused: i.unused, before: i.size, after: i.optimizedSize };
|
||||
},
|
||||
|
||||
/** The download is written without old copies and unused space (on = false undoes it). */
|
||||
"iopw:optimize"(id, on = true) {
|
||||
const s = session(id);
|
||||
work(s).optimize = !!on;
|
||||
return info(s);
|
||||
},
|
||||
|
||||
/** Drops every pending change of a pack. */
|
||||
"iopw:revert"(id) {
|
||||
const s = session(id);
|
||||
s.work = null;
|
||||
return info(s);
|
||||
},
|
||||
|
||||
/**
|
||||
* The pack with its changes, as a Blob: the original file's data (sliced, not copied), the
|
||||
* new records, a new central directory with only the live entries. Optimized: only the live
|
||||
* records, packed together.
|
||||
*/
|
||||
async "iopw:build"(id) {
|
||||
const s = session(id);
|
||||
const w = work(s);
|
||||
const base = w.base;
|
||||
const blob = await baseBlob(s);
|
||||
const live = w.liveEntries();
|
||||
const parts = [];
|
||||
const cd = [];
|
||||
let pos;
|
||||
if (w.optimize) {
|
||||
pos = 0;
|
||||
let run = null; // neighbouring base records go in as one slice
|
||||
const flush = () => run && (parts.push(blob.slice(run[0], run[1])), (run = null));
|
||||
live.forEach((e, i) => {
|
||||
if (i % 200 === 0) ctx.progress(i, live.length);
|
||||
let len;
|
||||
if (e.mem) {
|
||||
flush();
|
||||
parts.push(e.mem.rec);
|
||||
len = e.mem.rec.length;
|
||||
} else {
|
||||
len = base.recordLength(e);
|
||||
if (run && run[1] === e.offset) run[1] += len;
|
||||
else {
|
||||
flush();
|
||||
run = [e.offset, e.offset + len];
|
||||
}
|
||||
}
|
||||
cd.push(withOffset(e.raw, pos));
|
||||
pos += len;
|
||||
});
|
||||
flush();
|
||||
} else {
|
||||
parts.push(blob.slice(0, base.cdOffset));
|
||||
pos = base.cdOffset;
|
||||
for (const e of live) {
|
||||
if (e.mem) {
|
||||
parts.push(e.mem.rec);
|
||||
cd.push(withOffset(e.raw, pos));
|
||||
pos += e.mem.rec.length;
|
||||
} else cd.push(e.raw);
|
||||
}
|
||||
}
|
||||
if (pos > 0xffffffff) throw new Error("The pack would be bigger than 4 GB.");
|
||||
if (cd.length > MAX_ENTRIES) throw new Error("The pack would have more than 65,535 entries.");
|
||||
const dir = Buffer.concat(cd);
|
||||
parts.push(dir, endRecord(cd.length, dir.length, pos, base.comment));
|
||||
ctx.progress(live.length, live.length);
|
||||
const out = new Blob(parts, { type: "application/octet-stream" });
|
||||
return { name: s.name, blob: out, size: out.size, entries: cd.length };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Pack password profiles ("Keys"): [type 0, type 1]. Type 0 opens packs and pieces without
|
||||
// comment, type 1 pieces with comment "1".
|
||||
//
|
||||
// Built-in = every region the shared sources know (io3DEngine\ioLocalManagerParent.cpp
|
||||
// GetPacPassword of 2014Dev, 213LSK and 232LSK, decoded with its EncryptDecryptData). Visitors can
|
||||
// add their own (custom) passwords on the Game Folder page; those are kept in their browser only.
|
||||
export const BUILTIN_KEYS = [
|
||||
{ id: "indonesia", name: "Indonesia", passwords: ["T*$f40FRjfoe*(fl304d", "Mfe$%2049eFeodk*&31Z"] },
|
||||
{ id: "korea", name: "Korea (2019 / 2021 source)", passwords: ["iosuccess#@", "XrFrI0%3BF%!0Dcx$30-"] },
|
||||
{ id: "korea-2014", name: "Korea (2014 source)", passwords: ["Po28yAT7jY837Nt8egUj", "n8DI7796IK8kMBMqA29l"] },
|
||||
{ id: "us", name: "US / NA", passwords: ["eE39DkE!%E0", "Eg%^io03UT$Cvf921-!$"] },
|
||||
{ id: "taiwan", name: "Taiwan", passwords: ["iUT38#@49vnFdjf)(4sg", "Yi#weT%^903Unv0$2gfj"] },
|
||||
{ id: "thailand", name: "Thailand", passwords: ["K3$dls49YU#$#eoE3054", "-_495IUEVJdlsl++32ed"] },
|
||||
{ id: "japan", name: "Japan / Singapore", passwords: ["EDgei%^df930%#fj!_=]", "@7$gjTRreie][!323O++"] },
|
||||
{ id: "china", name: "China", passwords: ["-)4TRfkl-41$%dgkrm05", "|059rtuGReowo@##tkg0"] },
|
||||
{ id: "turkey", name: "Turkey", passwords: ["Efedf12-Asv", "fegG-24qw##4dfe52%3*"] },
|
||||
{ id: "latin", name: "Latin America", passwords: ["dus!qhdaksl", "tkdjqqn!dlfEhrqkfhgo"] },
|
||||
].map((k) => ({ ...k, builtin: true }));
|
||||
|
||||
let custom = [];
|
||||
|
||||
/** The visitor's own passwords: [{ id, name, passwords: [type0, type1] }]. Tried first. */
|
||||
export function setCustomKeys(list) {
|
||||
custom = (Array.isArray(list) ? list : [])
|
||||
.filter((k) => k && Array.isArray(k.passwords) && k.passwords[0])
|
||||
.map((k, i) => ({ id: String(k.id || `custom-${i}`), name: String(k.name || "Custom"), passwords: [String(k.passwords[0]), String(k.passwords[1] || k.passwords[0])], custom: true }));
|
||||
}
|
||||
|
||||
/** Custom passwords first, then the built-in ones. */
|
||||
export function allKeys() {
|
||||
return [...custom, ...BUILTIN_KEYS];
|
||||
}
|
||||
|
||||
/** Finds the key whose type-0 password opens the pack, by reading a few encrypted entries. */
|
||||
export function detectKey(pack) {
|
||||
const sample = pack.liveEntries().filter((e) => e.flags & 1 && e.csize > 12);
|
||||
const picks = [];
|
||||
for (let i = 0; i < sample.length && picks.length < 3; i += Math.max(1, Math.floor(sample.length / 3))) picks.push(sample[i]);
|
||||
if (!picks.length) return { key: null, encrypted: false };
|
||||
for (const k of allKeys()) {
|
||||
for (const pw of new Set(k.passwords)) {
|
||||
try {
|
||||
for (const e of picks) pack.read(e, pw);
|
||||
// a pack opened by the type-1 password: try that one first everywhere
|
||||
return { key: pw === k.passwords[0] ? k : { ...k, passwords: [pw, k.passwords[0]] }, encrypted: true };
|
||||
} catch {
|
||||
// wrong password or CRC: try the next one
|
||||
}
|
||||
}
|
||||
}
|
||||
return { key: null, encrypted: true };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Ported from LS Tools desktop: core/cfg.js (mechanical CJS -> ESM)
|
||||
// server_patch.cfg and delete_file_list.cfg, as the old PatchManager writes them:
|
||||
// [<path>.iop:<crc hex, lowercase, no padding>:<.iop size>:<file size>] CRLF after every line
|
||||
// Paths are lowercase with "/" and relative to the patch root. A "full.*" entry always goes first.
|
||||
|
||||
function parse(text) {
|
||||
const out = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.replace(/[[\]]/g, "").trim();
|
||||
if (!line) continue;
|
||||
const [p, crc, zip, unzip] = line.split(":");
|
||||
if (!p) continue;
|
||||
out.push({ path: p.replace(/\\/g, "/").toLowerCase(), crc: parseInt(crc, 16) >>> 0 || 0, zipSize: parseInt(zip, 10) || 0, size: parseInt(unzip, 10) || 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isFull = (p) => /^full\.[^/]+$/i.test(p.replace(/\.iop$/i, ""));
|
||||
|
||||
function serialize(entries) {
|
||||
const full = entries.filter((e) => isFull(e.path));
|
||||
const rest = entries.filter((e) => !isFull(e.path));
|
||||
return [...full.slice(0, 1), ...rest].map((e) => `[${e.path}:${(e.crc >>> 0).toString(16)}:${e.zipSize}:${e.size}]\r\n`).join("");
|
||||
}
|
||||
|
||||
/** Updates the entry with the same path (case-insensitive) or appends a new one. */
|
||||
function upsert(entries, entry) {
|
||||
const i = entries.findIndex((e) => e.path === entry.path.toLowerCase());
|
||||
const e = { ...entry, path: entry.path.toLowerCase() };
|
||||
if (i >= 0) entries[i] = e;
|
||||
else entries.push(e);
|
||||
return i < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert for many entries at once (publishing whole packs: 100 000 lines): a path → position map
|
||||
* instead of a search through the list for every file. Only this function may change `entries`.
|
||||
*/
|
||||
function indexed(entries) {
|
||||
const at = new Map(entries.map((e, i) => [e.path, i]));
|
||||
return (entry) => {
|
||||
const e = { ...entry, path: entry.path.toLowerCase() };
|
||||
const i = at.get(e.path);
|
||||
if (i === undefined) {
|
||||
at.set(e.path, entries.length);
|
||||
entries.push(e);
|
||||
return true;
|
||||
}
|
||||
entries[i] = e;
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// delete_file_list.cfg: one pack path per line, "[path]" — without ".iop", which is what both the
|
||||
// client (info\delete_file_list.cfg.iop) and the launcher expect.
|
||||
function parseDeleteList(text) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/[[\]]/g, "").split(":")[0].trim().replace(/\\/g, "/").toLowerCase().replace(/\.iop$/, ""))
|
||||
.filter((l) => l && !l.startsWith("please delete"));
|
||||
}
|
||||
const serializeDeleteList = (names) => [...new Set(names)].map((n) => `[${n}]\r\n`).join("");
|
||||
|
||||
export { parse, serialize, upsert, indexed, isFull, parseDeleteList, serializeDeleteList };
|
||||
export default { parse, serialize, upsert, indexed, isFull, parseDeleteList, serializeDeleteList };
|
||||
@@ -0,0 +1,574 @@
|
||||
// Ported from LS Tools desktop: core/iop.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import zlib from "node:zlib";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// .iop files = ZIP as written by ZipArchive 3.2.0 inside ioPac, with two field pairs swapped:
|
||||
// local header +4 name length, +6 extra length, ... +26 version needed, +28 flags
|
||||
// central entry +4 name length, +6 extra length, ... +28 version made by, +30 version needed
|
||||
// Everything else is standard: raw deflate, traditional PKWARE ZipCrypto, PK\5\6 end record.
|
||||
//
|
||||
// Packs (rs.iop, map.iop, ...) are opened by the client with the type-0 password only; within a
|
||||
// pack the entry with the highest offset wins, names compare case-insensitively with / or \.
|
||||
// So an update is appended at the end (where the old central directory was) and the central
|
||||
// directory is written again after it, like autoupgrade.exe's AddBigPac. Unlike AddBigPac, the
|
||||
// already-compressed data of a patch piece is reused (see preparePiece) instead of being
|
||||
// inflated and deflated again at level 9.
|
||||
|
||||
const SIG_LOCAL = 0x04034b50;
|
||||
const SIG_CENTRAL = 0x02014b50;
|
||||
const SIG_END = 0x06054b50;
|
||||
const SIG_DESC = 0x08074b50;
|
||||
const MAX_ENTRIES = 65535;
|
||||
|
||||
// ---------- ZipCrypto ----------
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
class ZipCrypto {
|
||||
constructor(password) {
|
||||
this.k0 = 305419896;
|
||||
this.k1 = 591751049;
|
||||
this.k2 = 878082192;
|
||||
for (const b of Buffer.from(password, "latin1")) this.update(b);
|
||||
}
|
||||
update(b) {
|
||||
this.k0 = (CRC_TABLE[(this.k0 ^ b) & 0xff] ^ (this.k0 >>> 8)) >>> 0;
|
||||
this.k1 = (Math.imul((this.k1 + (this.k0 & 0xff)) >>> 0, 134775813) + 1) >>> 0;
|
||||
this.k2 = (CRC_TABLE[(this.k2 ^ (this.k1 >>> 24)) & 0xff] ^ (this.k2 >>> 8)) >>> 0;
|
||||
}
|
||||
// The loops inline update()/stream(): they run over every byte of every patch file
|
||||
decrypt(buf) {
|
||||
const out = Buffer.allocUnsafeSlow(buf.length);
|
||||
let { k0, k1, k2 } = this;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const t = (k2 | 2) & 0xffff;
|
||||
const c = buf[i] ^ ((Math.imul(t, t ^ 1) >>> 8) & 0xff);
|
||||
out[i] = c;
|
||||
k0 = (CRC_TABLE[(k0 ^ c) & 0xff] ^ (k0 >>> 8)) >>> 0;
|
||||
k1 = (Math.imul((k1 + (k0 & 0xff)) >>> 0, 134775813) + 1) >>> 0;
|
||||
k2 = (CRC_TABLE[(k2 ^ (k1 >>> 24)) & 0xff] ^ (k2 >>> 8)) >>> 0;
|
||||
}
|
||||
Object.assign(this, { k0, k1, k2 });
|
||||
return out;
|
||||
}
|
||||
encrypt(buf) {
|
||||
const out = Buffer.allocUnsafeSlow(buf.length);
|
||||
let { k0, k1, k2 } = this;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const c = buf[i];
|
||||
const t = (k2 | 2) & 0xffff;
|
||||
out[i] = c ^ ((Math.imul(t, t ^ 1) >>> 8) & 0xff);
|
||||
k0 = (CRC_TABLE[(k0 ^ c) & 0xff] ^ (k0 >>> 8)) >>> 0;
|
||||
k1 = (Math.imul((k1 + (k0 & 0xff)) >>> 0, 134775813) + 1) >>> 0;
|
||||
k2 = (CRC_TABLE[(k2 ^ (k1 >>> 24)) & 0xff] ^ (k2 >>> 8)) >>> 0;
|
||||
}
|
||||
Object.assign(this, { k0, k1, k2 });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const crc32 = (buf) => zlib.crc32(buf) >>> 0;
|
||||
|
||||
// Pack passwords are kept the way the client source keeps them (szEncPassWord byte arrays) and
|
||||
// decoded with its XOR scheme (ioLocalParent.cpp EncryptDecryptData, n = 20). Only keeps them out
|
||||
// of plain sight: the client needs the same passwords, so they ship inside every game install.
|
||||
const PW_KEY = [255, 1, 2, 9, 89, 32, 123, 39, 34, 211, 222, 244, 100, 129, 23, 1, 4, 3, 29, 30, 1, 4, 5, 7, 8, 233, 89, 1, 98, 67];
|
||||
function decodePassword(bytes) {
|
||||
const n = bytes.length;
|
||||
const out = Buffer.from(bytes.map((b, i) => (b & 0xff) ^ PW_KEY[i % 30] ^ PW_KEY[(n - i) % 30]));
|
||||
const end = out.indexOf(0);
|
||||
return out.subarray(0, end < 0 ? out.length : end).toString("latin1");
|
||||
}
|
||||
|
||||
function dosTime(d = new Date()) {
|
||||
const year = Math.max(d.getFullYear(), 1980);
|
||||
return {
|
||||
time: ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff,
|
||||
date: (((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lookup key the client uses: lowercase, backslashes. */
|
||||
const keyOf = (name) => name.replace(/\//g, "\\").toLowerCase();
|
||||
|
||||
// ---------- central directory ----------
|
||||
function findEnd(tail) {
|
||||
for (let i = tail.length - 22; i >= 0; i--) {
|
||||
if (tail.readUInt32LE(i) === SIG_END) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseCentral(cd, count) {
|
||||
const entries = [];
|
||||
let p = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG_CENTRAL) throw new Error("Bad central directory");
|
||||
const nameLen = cd.readUInt16LE(p + 4);
|
||||
const extraLen = cd.readUInt16LE(p + 6);
|
||||
const commentLen = cd.readUInt16LE(p + 32);
|
||||
const size = 46 + nameLen + extraLen + commentLen;
|
||||
entries.push({
|
||||
name: cd.toString("latin1", p + 46, p + 46 + nameLen),
|
||||
nameLen,
|
||||
flags: cd.readUInt16LE(p + 8),
|
||||
method: cd.readUInt16LE(p + 10),
|
||||
time: cd.readUInt16LE(p + 12),
|
||||
date: cd.readUInt16LE(p + 14),
|
||||
crc: cd.readUInt32LE(p + 16),
|
||||
csize: cd.readUInt32LE(p + 20),
|
||||
usize: cd.readUInt32LE(p + 24),
|
||||
comment: cd.subarray(p + 46 + nameLen + extraLen, p + size),
|
||||
offset: cd.readUInt32LE(p + 42),
|
||||
raw: cd.subarray(p, p + size), // written back unchanged (offset patched on compaction)
|
||||
});
|
||||
p += size;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function centralRecord(e) {
|
||||
const b = Buffer.alloc(46);
|
||||
b.writeUInt32LE(SIG_CENTRAL, 0);
|
||||
b.writeUInt16LE(e.nameBuf.length, 4);
|
||||
b.writeUInt16LE(0, 6);
|
||||
b.writeUInt16LE(e.flags, 8);
|
||||
b.writeUInt16LE(e.method, 10);
|
||||
b.writeUInt16LE(e.time, 12);
|
||||
b.writeUInt16LE(e.date, 14);
|
||||
b.writeUInt32LE(e.crc, 16);
|
||||
b.writeUInt32LE(e.csize, 20);
|
||||
b.writeUInt32LE(e.usize, 24);
|
||||
b.writeUInt16LE(0x0014, 28); // version made by (DOS, 2.0) — no "ZL" extra field needed
|
||||
b.writeUInt16LE(0x0014, 30); // version needed
|
||||
b.writeUInt16LE(e.comment.length, 32);
|
||||
b.writeUInt32LE(e.offset, 42);
|
||||
return Buffer.concat([b, e.nameBuf, e.comment]);
|
||||
}
|
||||
|
||||
function endRecord(count, cdSize, cdOffset, comment) {
|
||||
const b = Buffer.alloc(22);
|
||||
b.writeUInt32LE(SIG_END, 0);
|
||||
b.writeUInt16LE(count, 8);
|
||||
b.writeUInt16LE(count, 10);
|
||||
b.writeUInt32LE(cdSize, 12);
|
||||
b.writeUInt32LE(cdOffset, 16);
|
||||
b.writeUInt16LE(comment.length, 20);
|
||||
return Buffer.concat([b, comment]);
|
||||
}
|
||||
|
||||
// ---------- single-file .iop (downloaded patch pieces, server_patch.cfg.iop) ----------
|
||||
|
||||
/** Entry 0 of a small .iop in memory: its directory entry and the raw (still packed) data. */
|
||||
function pieceEntry(buf) {
|
||||
const tail = buf.subarray(Math.max(0, buf.length - 0xffff - 22));
|
||||
const end = findEnd(tail);
|
||||
if (end < 0) throw new Error("Not a zip file");
|
||||
const count = tail.readUInt16LE(end + 10);
|
||||
const cdSize = tail.readUInt32LE(end + 12);
|
||||
const cdOffset = tail.readUInt32LE(end + 16);
|
||||
if (!count) throw new Error("Empty zip file");
|
||||
const [e] = parseCentral(buf.subarray(cdOffset, cdOffset + cdSize), 1);
|
||||
if (buf.readUInt32LE(e.offset) !== SIG_LOCAL) throw new Error("Bad local header");
|
||||
const start = e.offset + 30 + buf.readUInt16LE(e.offset + 4) + buf.readUInt16LE(e.offset + 6);
|
||||
if (start + e.csize > buf.length) throw new Error("Truncated file");
|
||||
return { e, raw: buf.subarray(start, start + e.csize) };
|
||||
}
|
||||
|
||||
/** Piece password: atoi(comment) == 1 → passwords[1], else passwords[0] (HTTPManager::AddPacFile). */
|
||||
const pieceType = (e) => (parseInt(e.comment.toString("latin1"), 10) === 1 ? 1 : 0);
|
||||
|
||||
/** Decrypts the piece's data if needed; returns the compressed (or stored) stream. */
|
||||
function unlock(e, raw, passwords) {
|
||||
if (!(e.flags & 1)) return raw;
|
||||
const password = passwords[pieceType(e)];
|
||||
if (!password) throw new Error("Encrypted piece without password");
|
||||
const zc = new ZipCrypto(password);
|
||||
const head = zc.decrypt(raw.subarray(0, 12));
|
||||
if (head[11] !== (e.flags & 8 ? e.time >>> 8 : e.crc >>> 24)) throw new Error("Wrong password");
|
||||
return zc.decrypt(raw.subarray(12));
|
||||
}
|
||||
|
||||
function inflateChecked(e, packed) {
|
||||
let data;
|
||||
if (e.method === 8) data = zlib.inflateRawSync(packed);
|
||||
else if (e.method === 0) data = packed;
|
||||
else throw new Error(`Unsupported method ${e.method}`);
|
||||
if (data.length !== e.usize || crc32(data) !== e.crc) throw new Error("CRC mismatch");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Entry 0 of a small .iop, decrypted, inflated and CRC-checked. */
|
||||
function readPiece(buf, passwords = []) {
|
||||
const { e, raw } = pieceEntry(buf);
|
||||
return { name: e.name, comment: e.comment, data: inflateChecked(e, unlock(e, raw, passwords)), crc: e.crc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a downloaded patch piece into a pack record, checking it on the way (decrypt + inflate
|
||||
* + CRC — the expensive deflate is never needed):
|
||||
* - encrypted with password 0 (the pack's password): its bytes are copied as they are
|
||||
* - otherwise (password 1, or not encrypted): the compressed stream is re-encrypted with
|
||||
* password 0; the compression itself is kept
|
||||
* For loose files (kind "loose") the plain data is returned instead.
|
||||
* Runs in the worker threads (iop-worker.js).
|
||||
*/
|
||||
function preparePiece(buf, passwords, kind = "pack") {
|
||||
const { e, raw } = pieceEntry(buf);
|
||||
const packed = unlock(e, raw, passwords);
|
||||
const data = inflateChecked(e, packed);
|
||||
const base = { name: e.name, comment: Buffer.from(e.comment), crc: e.crc, usize: e.usize };
|
||||
if (kind === "loose") return { ...base, data };
|
||||
if (!data.length) throw new Error("Empty file");
|
||||
|
||||
if (e.flags & 1 && pieceType(e) === 0) {
|
||||
return { ...base, record: { flags: e.flags, method: e.method, time: e.time, date: e.date, body: Buffer.from(raw) } };
|
||||
}
|
||||
const { time, date } = dosTime();
|
||||
const head = crypto.randomBytes(12);
|
||||
head[10] = time & 0xff;
|
||||
head[11] = time >>> 8; // check byte = time >> 8 because the entry gets a data descriptor
|
||||
const zc = new ZipCrypto(passwords[0]);
|
||||
const body = Buffer.concat([zc.encrypt(head), zc.encrypt(packed)]);
|
||||
// encrypted | data descriptor | keep the piece's compression-level bits
|
||||
return { ...base, record: { flags: (e.flags & 0x6) | 0x9, method: e.method, time, date, body } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads entries of a pack the way the client does (password 0: decrypt, inflate, CRC) and returns
|
||||
* the names that fail. `entries` are plain { name, offset, csize, usize, crc, flags, method, time }
|
||||
* (they come from Pack.liveEntries through a worker message). Runs in the worker threads.
|
||||
*/
|
||||
function verifyEntries(file, entries, password) {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const head = Buffer.alloc(30);
|
||||
const bad = [];
|
||||
try {
|
||||
for (const e of entries) {
|
||||
try {
|
||||
if (fs.readSync(fd, head, 0, 30, e.offset) !== 30 || head.readUInt32LE(0) !== SIG_LOCAL) throw new Error("bad local header");
|
||||
const start = e.offset + 30 + head.readUInt16LE(4) + head.readUInt16LE(6);
|
||||
let data = Buffer.allocUnsafe(e.csize);
|
||||
if (fs.readSync(fd, data, 0, e.csize, start) !== e.csize) throw new Error("truncated");
|
||||
if (e.flags & 1) {
|
||||
const zc = new ZipCrypto(password);
|
||||
const h = zc.decrypt(data.subarray(0, 12));
|
||||
if (h[11] !== (e.flags & 8 ? e.time >>> 8 : e.crc >>> 24)) throw new Error("wrong password");
|
||||
data = zc.decrypt(data.subarray(12));
|
||||
}
|
||||
inflateChecked(e, data);
|
||||
} catch {
|
||||
bad.push(e.name);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
// ---------- packs ----------
|
||||
|
||||
class Pack {
|
||||
/** Opens an existing pack (read/write unless readOnly); only the end record and central directory are read. */
|
||||
static open(file, { readOnly = false } = {}) {
|
||||
const fd = fs.openSync(file, readOnly ? "r" : "r+");
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size;
|
||||
const tailLen = Math.min(size, 0xffff + 22);
|
||||
const tail = Buffer.alloc(tailLen);
|
||||
fs.readSync(fd, tail, 0, tailLen, size - tailLen);
|
||||
const end = findEnd(tail);
|
||||
if (end < 0) throw new Error(`${file}: end record not found`);
|
||||
const count = tail.readUInt16LE(end + 10);
|
||||
const cdSize = tail.readUInt32LE(end + 12);
|
||||
const cdOffset = tail.readUInt32LE(end + 16);
|
||||
const commentLen = tail.readUInt16LE(end + 20);
|
||||
const comment = Buffer.from(tail.subarray(end + 22, end + 22 + commentLen));
|
||||
const cd = Buffer.alloc(cdSize);
|
||||
fs.readSync(fd, cd, 0, cdSize, cdOffset);
|
||||
return new Pack(file, fd, parseCentral(cd, count), cdOffset, comment, size);
|
||||
} catch (err) {
|
||||
fs.closeSync(fd);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(file, fd, entries, cdOffset, comment, size) {
|
||||
this.file = file;
|
||||
this.fd = fd;
|
||||
this.entries = entries.sort((a, b) => a.offset - b.offset);
|
||||
this.cdOffset = cdOffset;
|
||||
this.dataEnd = cdOffset; // where the next appended entry goes
|
||||
this.size = size;
|
||||
this.comment = comment;
|
||||
this.index = new Map();
|
||||
for (const e of this.entries) this.index.set(keyOf(e.name), e); // later offset wins
|
||||
this.dirty = false;
|
||||
this.removed = 0;
|
||||
}
|
||||
|
||||
/** The entry the client would load for this name, or undefined. */
|
||||
lookup(name) {
|
||||
return this.index.get(keyOf(name));
|
||||
}
|
||||
|
||||
/** Entries the client can load: the last copy of each name, in file order. */
|
||||
liveEntries() {
|
||||
return this.entries.filter((e) => this.index.get(keyOf(e.name)) === e);
|
||||
}
|
||||
|
||||
get backupFile() {
|
||||
return `${this.file}.lsl-backup`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a prepared record (see preparePiece) as the newest entry for `name`. The central
|
||||
* directory is only written by commit(); before the first change the old tail (central
|
||||
* directory + end record) is saved next to the pack so an interrupted update can be rolled back
|
||||
* with Pack.recover().
|
||||
*/
|
||||
appendRecord(name, { flags, method, time, date, body }, { crc, usize, comment }) {
|
||||
if (this.entries.length >= MAX_ENTRIES) throw new Error(`${this.file}: too many entries (optimize the pack)`);
|
||||
if (!this.dirty) this.saveBackup();
|
||||
const nameBuf = Buffer.from(name.replace(/\\/g, "/"), "latin1");
|
||||
const desc = (flags & 8) !== 0;
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(SIG_LOCAL, 0);
|
||||
local.writeUInt16LE(nameBuf.length, 4);
|
||||
local.writeUInt16LE(0, 6);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(time, 10);
|
||||
local.writeUInt16LE(date, 12);
|
||||
if (!desc) {
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(usize, 22);
|
||||
}
|
||||
local.writeUInt16LE(0x0014, 26);
|
||||
local.writeUInt16LE(flags, 28);
|
||||
const parts = [local, nameBuf, body];
|
||||
if (desc) {
|
||||
const d = Buffer.alloc(16);
|
||||
d.writeUInt32LE(SIG_DESC, 0);
|
||||
d.writeUInt32LE(crc, 4);
|
||||
d.writeUInt32LE(body.length, 8);
|
||||
d.writeUInt32LE(usize, 12);
|
||||
parts.push(d);
|
||||
}
|
||||
const record = Buffer.concat(parts);
|
||||
if (this.dataEnd + record.length > 0xffffffff) throw new Error(`${this.file}: pack would exceed 4 GB`);
|
||||
fs.writeSync(this.fd, record, 0, record.length, this.dataEnd);
|
||||
|
||||
const entry = {
|
||||
name: nameBuf.toString("latin1"),
|
||||
nameBuf,
|
||||
nameLen: nameBuf.length,
|
||||
flags,
|
||||
method,
|
||||
time,
|
||||
date,
|
||||
crc,
|
||||
csize: body.length,
|
||||
usize,
|
||||
comment: Buffer.from(comment || []),
|
||||
offset: this.dataEnd,
|
||||
};
|
||||
entry.raw = centralRecord(entry);
|
||||
this.entries.push(entry);
|
||||
this.index.set(keyOf(entry.name), entry);
|
||||
this.dataEnd += record.length;
|
||||
this.dirty = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Appends plain data (deflate level 9 + password encryption, like AddBigPac). */
|
||||
append(name, data, comment, password) {
|
||||
if (!data.length) throw new Error(`${name}: empty file`);
|
||||
const { time, date } = dosTime();
|
||||
const head = crypto.randomBytes(12);
|
||||
head[10] = time & 0xff;
|
||||
head[11] = time >>> 8;
|
||||
const zc = new ZipCrypto(password);
|
||||
const body = Buffer.concat([zc.encrypt(head), zc.encrypt(zlib.deflateRawSync(data, { level: 9 }))]);
|
||||
return this.appendRecord(name, { flags: 0x000b, method: 8, time, date, body }, { crc: crc32(data), usize: data.length, comment });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every copy of these names from the pack (delete_file_list.cfg, like
|
||||
* DeleteFileInZip(name, -1)). Their data stays in the file as unused space until optimize().
|
||||
* @returns {number} names that were in the pack
|
||||
*/
|
||||
remove(names) {
|
||||
let n = 0;
|
||||
for (const name of names) {
|
||||
const key = keyOf(name);
|
||||
if (!this.index.has(key)) continue;
|
||||
if (!this.dirty) this.saveBackup();
|
||||
this.index.delete(key);
|
||||
this.entries = this.entries.filter((e) => keyOf(e.name) !== key);
|
||||
this.dirty = true;
|
||||
n++;
|
||||
}
|
||||
this.removed += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the central directory + end record after the data and drops the backup. Only the
|
||||
* copy the client would load is kept for each name; older copies become unused space.
|
||||
*/
|
||||
commit() {
|
||||
if (!this.dirty) return;
|
||||
this.entries = this.liveEntries();
|
||||
const cd = Buffer.concat(this.entries.map((e) => e.raw));
|
||||
const tail = Buffer.concat([cd, endRecord(this.entries.length, cd.length, this.dataEnd, this.comment)]);
|
||||
fs.writeSync(this.fd, tail, 0, tail.length, this.dataEnd);
|
||||
fs.ftruncateSync(this.fd, this.dataEnd + tail.length);
|
||||
fs.fsyncSync(this.fd);
|
||||
this.cdOffset = this.dataEnd;
|
||||
this.size = this.dataEnd + tail.length;
|
||||
this.dirty = false;
|
||||
fs.rmSync(this.backupFile, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes in the data area not used by any live entry (old copies, removed files). Estimated from
|
||||
* the directory: the local header of these packs has no extra field, and entries with a data
|
||||
* descriptor carry the 16-byte signed form.
|
||||
*/
|
||||
stats() {
|
||||
const live = this.liveEntries();
|
||||
const used = live.reduce((n, e) => n + 30 + e.nameLen + e.csize + (e.flags & 8 ? 16 : 0), 0);
|
||||
return { entries: live.length, used, unused: Math.max(0, this.dataEnd - used), size: this.size };
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.fd !== null) fs.closeSync(this.fd);
|
||||
this.fd = null;
|
||||
}
|
||||
|
||||
saveBackup() {
|
||||
const size = fs.fstatSync(this.fd).size;
|
||||
const tail = Buffer.alloc(size - this.cdOffset);
|
||||
fs.readSync(this.fd, tail, 0, tail.length, this.cdOffset);
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeBigUInt64LE(BigInt(this.cdOffset));
|
||||
fs.writeFileSync(this.backupFile, Buffer.concat([head, tail]));
|
||||
}
|
||||
|
||||
/** Rolls a pack back to its state before an update that didn't finish (backup file present). */
|
||||
static recover(file) {
|
||||
const backup = `${file}.lsl-backup`;
|
||||
if (!fs.existsSync(backup)) return false;
|
||||
const buf = fs.readFileSync(backup);
|
||||
const offset = Number(buf.readBigUInt64LE(0));
|
||||
const fd = fs.openSync(file, "r+");
|
||||
try {
|
||||
fs.writeSync(fd, buf, 8, buf.length - 8, offset);
|
||||
fs.ftruncateSync(fd, offset + buf.length - 8);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.rmSync(backup, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Exact size of an entry's record in the file: local header + name + extra + data (+ descriptor). */
|
||||
recordLength(e) {
|
||||
const head = Buffer.alloc(30);
|
||||
fs.readSync(this.fd, head, 0, 30, e.offset);
|
||||
if (head.readUInt32LE(0) !== SIG_LOCAL) throw new Error(`${this.file}: bad local header for ${e.name}`);
|
||||
let len = 30 + head.readUInt16LE(4) + head.readUInt16LE(6) + e.csize;
|
||||
if (e.flags & 8) {
|
||||
const sig = Buffer.alloc(4);
|
||||
fs.readSync(this.fd, sig, 0, 4, e.offset + len);
|
||||
len += sig.readUInt32LE(0) === SIG_DESC ? 16 : 12;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the pack with only the live entries, dropping old copies and removed files (like
|
||||
* autoupgrade's CreateNewPacFile, but copying the stored bytes instead of re-compressing).
|
||||
* Writes <pack>.lsl-optimize, then replaces the pack; the Pack is closed afterwards.
|
||||
* @returns {Promise<{before: number, after: number}>}
|
||||
*/
|
||||
async optimize({ signal, progress = () => {} } = {}) {
|
||||
if (this.dirty) this.commit();
|
||||
const live = this.liveEntries();
|
||||
const tmp = `${this.file}.lsl-optimize`;
|
||||
const out = await fsp.open(tmp, "w");
|
||||
const before = this.size;
|
||||
try {
|
||||
const total = live.reduce((n, e) => n + e.csize, 0) || 1;
|
||||
const chunk = Buffer.allocUnsafe(4 * 1024 * 1024);
|
||||
let pos = 0;
|
||||
let copied = 0;
|
||||
const cd = [];
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
if (signal?.aborted) throw Object.assign(new Error("canceled"), { code: "canceled" });
|
||||
const e = live[i];
|
||||
const len = this.recordLength(e);
|
||||
for (let off = 0; off < len; ) {
|
||||
const n = fs.readSync(this.fd, chunk, 0, Math.min(chunk.length, len - off), e.offset + off);
|
||||
if (!n) throw new Error(`${this.file}: unexpected end of file`);
|
||||
await out.write(chunk, 0, n, pos + off);
|
||||
off += n;
|
||||
}
|
||||
const raw = Buffer.from(e.raw);
|
||||
raw.writeUInt32LE(pos, 42);
|
||||
cd.push(raw);
|
||||
pos += len;
|
||||
copied += e.csize;
|
||||
if (i % 200 === 0) progress({ done: copied, total });
|
||||
}
|
||||
const dir = Buffer.concat(cd);
|
||||
await out.write(Buffer.concat([dir, endRecord(cd.length, dir.length, pos, this.comment)]), 0, undefined, pos);
|
||||
await out.sync();
|
||||
await out.close();
|
||||
this.close();
|
||||
fs.renameSync(tmp, this.file);
|
||||
progress({ done: total, total });
|
||||
return { before, after: fs.statSync(this.file).size };
|
||||
} catch (err) {
|
||||
await out.close().catch(() => {});
|
||||
fs.rmSync(tmp, { force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypts/inflates one entry (for tests and checks). */
|
||||
read(entry, password) {
|
||||
const head = Buffer.alloc(30);
|
||||
fs.readSync(this.fd, head, 0, 30, entry.offset);
|
||||
if (head.readUInt32LE(0) !== SIG_LOCAL) throw new Error("Bad local header");
|
||||
const start = entry.offset + 30 + head.readUInt16LE(4) + head.readUInt16LE(6);
|
||||
let data = Buffer.alloc(entry.csize);
|
||||
fs.readSync(this.fd, data, 0, entry.csize, start);
|
||||
if (entry.flags & 1) {
|
||||
const zc = new ZipCrypto(password);
|
||||
const h = zc.decrypt(data.subarray(0, 12));
|
||||
const check = entry.flags & 8 ? entry.time >>> 8 : entry.crc >>> 24;
|
||||
if (h[11] !== check) throw new Error("Wrong password");
|
||||
data = zc.decrypt(data.subarray(12));
|
||||
}
|
||||
if (entry.method === 8) data = zlib.inflateRawSync(data);
|
||||
if (crc32(data) !== entry.crc) throw new Error("CRC mismatch");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export { Pack, readPiece, preparePiece, verifyEntries, crc32, ZipCrypto, keyOf, decodePassword };
|
||||
export default { Pack, readPiece, preparePiece, verifyEntries, crc32, ZipCrypto, keyOf, decodePassword };
|
||||
@@ -0,0 +1,369 @@
|
||||
// Ported from LS Tools desktop: core/tools.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import crypto from "node:crypto";
|
||||
import { Pack, ZipCrypto, crc32, keyOf } from "./iop.js";
|
||||
|
||||
// Admin-side .iop operations on top of core/iop.js (shared with the launcher): patch pieces the
|
||||
// way PatchManager builds them, password detection and switching, verify, extract, rename.
|
||||
|
||||
const SIG_LOCAL = 0x04034b50;
|
||||
const SIG_CENTRAL = 0x02014b50;
|
||||
const SIG_END = 0x06054b50;
|
||||
const SIG_DESC = 0x08074b50;
|
||||
|
||||
function dosTime(d) {
|
||||
const year = Math.max(d.getFullYear(), 1980);
|
||||
return {
|
||||
time: ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff,
|
||||
date: (((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff,
|
||||
};
|
||||
}
|
||||
|
||||
/** DOS date/time → JS Date (for display). */
|
||||
function fromDos(date, time) {
|
||||
return new Date(1980 + (date >> 9), ((date >> 5) & 15) - 1, date & 31, time >> 11, (time >> 5) & 63, (time & 31) * 2);
|
||||
}
|
||||
|
||||
// ---------- patch pieces (PatchManager output) ----------
|
||||
|
||||
/**
|
||||
* One-entry .iop exactly like the old PatchManager writes them (checked against the backups):
|
||||
* entry named by the bare file name, raw deflate level 6, flag 0, CRC and sizes in the local
|
||||
* header, DOS time from the file's modified time, external attributes 0x81B40020, no comment,
|
||||
* no encryption. Like ZipArchive, a file that deflate doesn't make smaller (tiny files such as
|
||||
* lstw.txt) is stored as is (method 0).
|
||||
*/
|
||||
function makePiece(fileName, data, mtime = new Date()) {
|
||||
const name = Buffer.from(path.basename(fileName), "latin1");
|
||||
const deflated = data.length ? zlib.deflateRawSync(data, { level: 6 }) : null;
|
||||
const method = deflated && deflated.length < data.length ? 8 : 0;
|
||||
const body = method ? deflated : data;
|
||||
const crc = crc32(data);
|
||||
const { time, date } = dosTime(mtime);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(SIG_LOCAL, 0);
|
||||
local.writeUInt16LE(name.length, 4);
|
||||
local.writeUInt16LE(0, 6);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(time, 10);
|
||||
local.writeUInt16LE(date, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(0x0014, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(SIG_CENTRAL, 0);
|
||||
central.writeUInt16LE(name.length, 4);
|
||||
central.writeUInt16LE(0, 8);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt16LE(time, 12);
|
||||
central.writeUInt16LE(date, 14);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(0x0014, 28);
|
||||
central.writeUInt16LE(0x0014, 30);
|
||||
central.writeUInt32LE(0x81b40020, 38);
|
||||
central.writeUInt32LE(0, 42);
|
||||
|
||||
const cdSize = 46 + name.length;
|
||||
const cdOffset = 30 + name.length + body.length;
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(SIG_END, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(cdSize, 12);
|
||||
end.writeUInt32LE(cdOffset, 16);
|
||||
return { buf: Buffer.concat([local, name, body, central, name, end]), crc, size: data.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* A patch piece straight from a pack entry: its stored bytes are copied, never inflated or
|
||||
* deflated again, so a whole pack turns into pieces at disk speed. Same compression, same
|
||||
* encryption (the pack's type-0 password — what the launcher and autoupgrade use for a piece
|
||||
* without comment "1"), same CRC and sizes, so the cfg line is the entry's. An entry with comment
|
||||
* "1" keeps its comment (the client's .ini XOR) and is re-encrypted with the type-1 password,
|
||||
* because a piece's comment picks the password it is opened with.
|
||||
*
|
||||
* `target` = the passwords of the server the piece is for (default: the pack's own). A pack from
|
||||
* another region (e.g. a Korean pack published to HK) is decrypted with its own password and
|
||||
* encrypted again with the target's, so the players' launchers can open the piece; the compressed
|
||||
* data stays as it is.
|
||||
*/
|
||||
function pieceFromEntry(pack, e, passwords = [], target = passwords) {
|
||||
let body = readRaw(pack, e);
|
||||
const comment = Buffer.from(e.comment || []);
|
||||
if (e.flags & 1) {
|
||||
const typeOne = parseInt(comment.toString("latin1"), 10) === 1;
|
||||
const want = typeOne ? target[1] : target[0];
|
||||
if (typeOne || want !== passwords[0]) {
|
||||
if (!passwords[0]) throw new Error(`${e.name}: no password opens its pack`);
|
||||
if (!want) throw new Error(`${e.name}: the target has no type-${typeOne ? 1 : 0} password`);
|
||||
const zc = new ZipCrypto(passwords[0]);
|
||||
if (zc.decrypt(body.subarray(0, 12))[11] !== checkByte(e)) throw new Error(`${e.name}: wrong password`);
|
||||
body = encryptBody(zc.decrypt(body.subarray(12)), want, e.flags, e);
|
||||
}
|
||||
}
|
||||
const name = Buffer.from(path.posix.basename(e.name.replace(/\\/g, "/")), "latin1");
|
||||
const desc = (e.flags & 8) !== 0;
|
||||
|
||||
// header fields in the .iop order (name/extra length at +4/+6, version/flags at +26/+28)
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(SIG_LOCAL, 0);
|
||||
local.writeUInt16LE(name.length, 4);
|
||||
local.writeUInt16LE(e.method, 8);
|
||||
local.writeUInt16LE(e.time, 10);
|
||||
local.writeUInt16LE(e.date, 12);
|
||||
if (!desc) {
|
||||
local.writeUInt32LE(e.crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(e.usize, 22);
|
||||
}
|
||||
local.writeUInt16LE(0x0014, 26);
|
||||
local.writeUInt16LE(e.flags, 28);
|
||||
const parts = [local, name, body];
|
||||
if (desc) {
|
||||
const d = Buffer.alloc(16);
|
||||
d.writeUInt32LE(SIG_DESC, 0);
|
||||
d.writeUInt32LE(e.crc, 4);
|
||||
d.writeUInt32LE(body.length, 8);
|
||||
d.writeUInt32LE(e.usize, 12);
|
||||
parts.push(d);
|
||||
}
|
||||
const cdOffset = parts.reduce((n, b) => n + b.length, 0);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(SIG_CENTRAL, 0);
|
||||
central.writeUInt16LE(name.length, 4);
|
||||
central.writeUInt16LE(e.flags, 8);
|
||||
central.writeUInt16LE(e.method, 10);
|
||||
central.writeUInt16LE(e.time, 12);
|
||||
central.writeUInt16LE(e.date, 14);
|
||||
central.writeUInt32LE(e.crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(e.usize, 24);
|
||||
central.writeUInt16LE(0x0014, 28);
|
||||
central.writeUInt16LE(0x0014, 30);
|
||||
central.writeUInt16LE(comment.length, 32);
|
||||
central.writeUInt32LE(0x81b40020, 38);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(SIG_END, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(46 + name.length + comment.length, 12);
|
||||
end.writeUInt32LE(cdOffset, 16);
|
||||
return { buf: Buffer.concat([...parts, central, name, comment, end]), crc: e.crc >>> 0, size: e.usize };
|
||||
}
|
||||
|
||||
// ---------- .ini XOR (client: comment "1" on an .ini entry) ----------
|
||||
const INI_KEY = [48, 29, 96, 1, 9, 48, 57, 213, 178, 123, 67, 90, 2, 4, 254, 255, 6, 8, 9, 23, 90, 44, 214, 199, 108, 119, 3, 2, 2, 0];
|
||||
/** XOR used by the client for .ini entries whose comment is "1" (same call encodes and decodes). */
|
||||
function iniXor(buf) {
|
||||
const n = buf.length;
|
||||
const out = Buffer.from(buf);
|
||||
for (let i = 0; i < n; i++) out[i] ^= INI_KEY[i % 30] ^ INI_KEY[(n - i) % 30];
|
||||
return out;
|
||||
}
|
||||
const isXorIni = (name, comment) => /\.ini/.test(name) && parseInt(Buffer.from(comment || []).toString("latin1"), 10) === 1;
|
||||
|
||||
// ---------- raw entry access ----------
|
||||
|
||||
/** Local header info and the stored bytes (still compressed/encrypted) of a pack entry. */
|
||||
function readRaw(pack, e) {
|
||||
const head = Buffer.alloc(30);
|
||||
fs.readSync(pack.fd, head, 0, 30, e.offset);
|
||||
if (head.readUInt32LE(0) !== SIG_LOCAL) throw new Error(`bad local header for ${e.name}`);
|
||||
const start = e.offset + 30 + head.readUInt16LE(4) + head.readUInt16LE(6);
|
||||
const body = Buffer.alloc(e.csize);
|
||||
fs.readSync(pack.fd, body, 0, e.csize, start);
|
||||
return body;
|
||||
}
|
||||
|
||||
const checkByte = (e) => (e.flags & 8 ? e.time >>> 8 : e.crc >>> 24);
|
||||
|
||||
/** Does `password` open this encrypted entry? (header check byte, then a full CRC check) */
|
||||
function passwordOpens(pack, e, password) {
|
||||
if (!(e.flags & 1)) return true;
|
||||
const body = readRaw(pack, e);
|
||||
const head = new ZipCrypto(password).decrypt(body.subarray(0, 12));
|
||||
if (head[11] !== checkByte(e)) return false;
|
||||
try {
|
||||
return pack.read(e, password).length === e.usize;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which password profile opens this pack/piece. Tries type 0 then type 1 of every profile on up
|
||||
* to three encrypted entries. Returns { profileId, type } or null (no encrypted entries → "none").
|
||||
*/
|
||||
function detectPassword(pack, profiles) {
|
||||
const samples = pack.liveEntries().filter((e) => e.flags & 1 && e.usize > 0).slice(0, 3);
|
||||
if (!samples.length) return { profileId: null, type: null, encrypted: false };
|
||||
for (const p of profiles) {
|
||||
for (const type of [0, 1]) {
|
||||
const pw = p.passwords[type];
|
||||
if (pw && samples.every((e) => passwordOpens(pack, e, pw))) return { profileId: p.id, type, encrypted: true };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- operations ----------
|
||||
|
||||
/** Decrypts (if needed), inflates and CRC-checks one entry; .ini XOR undone when `xorIni`. */
|
||||
function readEntry(pack, e, password, { xorIni = true } = {}) {
|
||||
let data = pack.read(e, password);
|
||||
if (xorIni && isXorIni(e.name, e.comment)) data = iniXor(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Extracts entries under `dest`, keeping their folders. `strip` removes a leading folder. */
|
||||
async function extract(pack, entries, dest, password, { xorIni = true, progress = () => {}, signal } = {}) {
|
||||
let done = 0;
|
||||
const failed = [];
|
||||
for (const e of entries) {
|
||||
if (signal?.aborted) throw Object.assign(new Error("canceled"), { code: "canceled" });
|
||||
if (e.name.endsWith("/")) continue;
|
||||
const rel = e.name.replace(/\\/g, "/").split("/").filter((s) => s && s !== ".." && s !== ".");
|
||||
const out = path.join(dest, ...rel);
|
||||
if (!out.startsWith(path.resolve(dest) + path.sep)) continue;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, readEntry(pack, e, password, { xorIni }));
|
||||
} catch (err) {
|
||||
failed.push({ name: e.name, error: err.message });
|
||||
}
|
||||
if (++done % 50 === 0) {
|
||||
progress({ done, total: entries.length });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
}
|
||||
progress({ done: entries.length, total: entries.length });
|
||||
return { extracted: done - failed.length, failed };
|
||||
}
|
||||
|
||||
/** Checks every live entry (decrypt + inflate + CRC). */
|
||||
async function verify(pack, password, { progress = () => {}, signal } = {}) {
|
||||
const live = pack.liveEntries().filter((e) => !e.name.endsWith("/"));
|
||||
const bad = [];
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
if (signal?.aborted) throw Object.assign(new Error("canceled"), { code: "canceled" });
|
||||
try {
|
||||
pack.read(live[i], password);
|
||||
} catch (err) {
|
||||
bad.push({ name: live[i].name, error: err.message });
|
||||
}
|
||||
if (i % 200 === 0) {
|
||||
progress({ done: i, total: live.length });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
}
|
||||
progress({ done: live.length, total: live.length });
|
||||
return { checked: live.length, bad };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds files as new entries (newest copy wins). Encrypted with `password` (the pack's type-0
|
||||
* password) unless it's null. `comment` "1" marks .ini content for the client's XOR, and the
|
||||
* content is XOR-encoded then (like the old tools did when adding with comment 1).
|
||||
*/
|
||||
function addFile(pack, name, data, { password, comment = "", level = 9, mtime = new Date() } = {}) {
|
||||
let content = data;
|
||||
if (isXorIni(name, Buffer.from(comment))) content = iniXor(content);
|
||||
const packed = content.length ? zlib.deflateRawSync(content, { level }) : Buffer.alloc(0);
|
||||
const method = content.length ? 8 : 0;
|
||||
const levelBits = level >= 8 ? 2 : level === 2 ? 4 : level === 1 ? 6 : 0;
|
||||
const { time, date } = dosTime(mtime);
|
||||
let body = packed;
|
||||
let flags = levelBits;
|
||||
if (password) {
|
||||
const head = crypto.randomBytes(12);
|
||||
head[10] = time & 0xff;
|
||||
head[11] = time >>> 8;
|
||||
const zc = new ZipCrypto(password);
|
||||
body = Buffer.concat([zc.encrypt(head), zc.encrypt(packed)]);
|
||||
flags |= 0x9;
|
||||
}
|
||||
return pack.appendRecord(name.replace(/\\/g, "/"), { flags, method, time, date, body }, { crc: crc32(content), usize: content.length, comment: Buffer.from(comment, "latin1") });
|
||||
}
|
||||
|
||||
/** Renames an entry: its stored bytes are appended under the new name, the old name removed. */
|
||||
function rename(pack, e, newName) {
|
||||
const body = readRaw(pack, e);
|
||||
pack.appendRecord(newName.replace(/\\/g, "/"), { flags: e.flags, method: e.method, time: e.time, date: e.date, body }, e);
|
||||
if (keyOf(newName) !== keyOf(e.name)) pack.remove([e.name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a copy of the pack re-encrypted from one password to another (live entries only), then
|
||||
* replaces the original. Compressed data is kept; only the encryption changes. Entries that
|
||||
* weren't encrypted stay as they are unless `encryptAll`.
|
||||
*/
|
||||
async function reencrypt(pack, fromPassword, toPassword, { encryptAll = false, progress = () => {}, signal } = {}) {
|
||||
const live = pack.liveEntries();
|
||||
const tmp = `${pack.file}.lsl-reencrypt`;
|
||||
const empty = Buffer.alloc(22);
|
||||
empty.writeUInt32LE(SIG_END, 0);
|
||||
fs.writeFileSync(tmp, empty);
|
||||
const out = Pack.open(tmp);
|
||||
try {
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
if (signal?.aborted) throw Object.assign(new Error("canceled"), { code: "canceled" });
|
||||
const e = live[i];
|
||||
let body = readRaw(pack, e);
|
||||
let flags = e.flags;
|
||||
let time = e.time;
|
||||
if (e.flags & 1) {
|
||||
const zc = new ZipCrypto(fromPassword);
|
||||
const head = zc.decrypt(body.subarray(0, 12));
|
||||
if (head[11] !== checkByte(e)) throw new Error(`wrong password for ${e.name}`);
|
||||
const packed = zc.decrypt(body.subarray(12));
|
||||
body = encryptBody(packed, toPassword, flags, e);
|
||||
} else if (encryptAll && toPassword && e.usize > 0) {
|
||||
flags |= 0x9;
|
||||
const { time: t } = dosTime(new Date());
|
||||
time = t;
|
||||
body = encryptBody(body, toPassword, flags, { ...e, time });
|
||||
}
|
||||
out.appendRecord(e.name, { flags, method: e.method, time, date: e.date, body }, e);
|
||||
if (i % 200 === 0) {
|
||||
progress({ done: i, total: live.length });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
}
|
||||
out.commit();
|
||||
out.close();
|
||||
pack.close();
|
||||
fs.renameSync(tmp, pack.file);
|
||||
progress({ done: live.length, total: live.length });
|
||||
} catch (err) {
|
||||
out.close();
|
||||
fs.rmSync(tmp, { force: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function encryptBody(packed, password, flags, e) {
|
||||
const head = crypto.randomBytes(12);
|
||||
head[10] = e.time & 0xff;
|
||||
head[11] = flags & 8 ? e.time >>> 8 : e.crc >>> 24;
|
||||
const zc = new ZipCrypto(password);
|
||||
return Buffer.concat([zc.encrypt(head), zc.encrypt(packed)]);
|
||||
}
|
||||
|
||||
/** New empty pack (22-byte end record, like autoupgrade's CreateNewPacFile). */
|
||||
function createPack(file) {
|
||||
const b = Buffer.alloc(22);
|
||||
b.writeUInt32LE(SIG_END, 0);
|
||||
fs.writeFileSync(file, b);
|
||||
}
|
||||
|
||||
export { makePiece, pieceFromEntry, iniXor, isXorIni, detectPassword, readEntry, extract, verify, addFile, rename, reencrypt, createPack, fromDos, readRaw };
|
||||
export default { makePiece, pieceFromEntry, iniXor, isXorIni, detectPassword, readEntry, extract, verify, addFile, rename, reencrypt, createPack, fromDos, readRaw };
|
||||
@@ -0,0 +1,302 @@
|
||||
// Ported from LS Tools desktop: main/assets.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Pack } from "../core/iop.js";
|
||||
import tools from "../core/tools.js";
|
||||
import iop from "./iopman.js";
|
||||
import store from "./store.js";
|
||||
|
||||
// Game resources for the viewers (textures, meshes, skeletons, animations, materials, models),
|
||||
// read from an "asset source":
|
||||
// session every .iop next to a pack open in IOP Manager (password of that session)
|
||||
// packs every .iop in a chosen folder (password found per pack from the profiles)
|
||||
// dir a folder of extracted files (e.g. map.iop extracted)
|
||||
// The client finds resources by bare file name inside a known folder (Texture/<name>,
|
||||
// Mesh/<name>.msh, …), so lookups go by name with a folder hint: resource/<folder>/<name> wins,
|
||||
// then any path with /<folder>/ in it, then (unless strict) the name anywhere.
|
||||
// Packs are searched in the client's order: rs.iop first, then map.iop (ioPac::GetPacHasFile asks the
|
||||
// packs in the order Login.cpp adds them and takes the first pack that has the name). Other packs
|
||||
// (any other .iop in the folder) come after, by name; their place in the client is not confirmed yet.
|
||||
// Inside one pack the last directory entry for a name wins (Pack.liveEntries).
|
||||
|
||||
const sources = new Map(); // key → { label, find(base, folder, strict), list(), close() }
|
||||
|
||||
function packSource(folder, passwordsFor) {
|
||||
const rank = (n) => (n === "rs.iop" ? 0 : n === "map.iop" ? 1 : 2);
|
||||
const names = fs
|
||||
.readdirSync(folder)
|
||||
.filter((n) => /\.iop$/i.test(n) && fs.statSync(path.join(folder, n)).isFile())
|
||||
.sort((a, b) => rank(a.toLowerCase()) - rank(b.toLowerCase()) || a.localeCompare(b));
|
||||
const packs = [];
|
||||
for (const n of names) {
|
||||
try {
|
||||
const pack = Pack.open(path.join(folder, n), { readOnly: true });
|
||||
packs.push({ name: n, file: path.join(folder, n), pack, passwords: passwordsFor(pack) });
|
||||
} catch (err) {
|
||||
console.warn("[assets] can't open", n, err.message);
|
||||
}
|
||||
}
|
||||
const index = new Map(); // base name → [{ p, entry, lower }]
|
||||
for (const p of packs) {
|
||||
for (const e of p.pack.liveEntries()) {
|
||||
const lower = e.name.toLowerCase();
|
||||
const base = lower.split("/").pop();
|
||||
if (!base) continue;
|
||||
if (!index.has(base)) index.set(base, []);
|
||||
index.get(base).push({ p, entry: e, lower });
|
||||
}
|
||||
}
|
||||
// entries with comment "1" use the type-1 password (and .ini files are XOR-scrambled)
|
||||
const hitOf = (h) => ({
|
||||
read: () => {
|
||||
const preferred = h.entry.comment.toString("latin1") === "1" ? 1 : 0;
|
||||
const passwords = h.p.passwords;
|
||||
try {
|
||||
return tools.readEntry(h.p.pack, h.entry, passwords?.[preferred] ?? null, { xorIni: true });
|
||||
} catch (err) {
|
||||
// Some client packs mark entries "1" but encrypt them with the type-0
|
||||
// key. Retry only a password mismatch with the other known key.
|
||||
// Web port fix: a wrong key passes the 1-byte ZipCrypto check 1 time in 256 and then fails
|
||||
// later (inflate "invalid distance" / CRC), so retry on any read error, not only
|
||||
// "wrong password", and report the first error if the other key fails too.
|
||||
if (!passwords || passwords[1 - preferred] == null) throw err;
|
||||
try {
|
||||
return tools.readEntry(h.p.pack, h.entry, passwords[1 - preferred], { xorIni: true });
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
source: `${h.p.name}: ${h.entry.name}`,
|
||||
where: { kind: "pack", file: h.p.file, entry: h.entry.name },
|
||||
});
|
||||
return {
|
||||
label: `${packs.length} pack(s) in ${folder}`,
|
||||
find(base, folder, strict) {
|
||||
const h = pick(index.get(base) || [], (x) => x.lower, folder, strict);
|
||||
return h ? hitOf(h) : null;
|
||||
},
|
||||
bySuffix(suffix) {
|
||||
const s = "/" + suffix.toLowerCase();
|
||||
const h = (index.get(s.split("/").pop()) || []).find((x) => ("/" + x.lower).endsWith(s));
|
||||
return h ? hitOf(h) : null;
|
||||
},
|
||||
get(key) {
|
||||
const [pn, ...rest] = key.split("|");
|
||||
const entryName = rest.join("|");
|
||||
const p = packs.find((x) => x.name === pn);
|
||||
const e = p?.pack.lookup(entryName);
|
||||
return e ? hitOf({ p, entry: e }) : null;
|
||||
},
|
||||
list: () => packs.flatMap((p) => p.pack.liveEntries().map((e) => ({ key: `${p.name}|${e.name}`, path: e.name, pack: p.name }))),
|
||||
close: () => packs.forEach((p) => p.pack.close()),
|
||||
};
|
||||
}
|
||||
|
||||
function dirSource(folder) {
|
||||
const index = new Map(); // base name → [{ full, lower (relative, /) }]
|
||||
const all = [];
|
||||
const walk = (dir, depth) => {
|
||||
if (depth > 12) return;
|
||||
let list;
|
||||
try {
|
||||
list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const d of list) {
|
||||
const full = path.join(dir, d.name);
|
||||
if (d.isDirectory()) walk(full, depth + 1);
|
||||
else if (d.isFile()) {
|
||||
const rel = path.relative(folder, full).replace(/\\/g, "/");
|
||||
const base = d.name.toLowerCase();
|
||||
if (!index.has(base)) index.set(base, []);
|
||||
index.get(base).push({ full, rel, lower: rel.toLowerCase() });
|
||||
all.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(folder, 0);
|
||||
const hitOf = (h) => ({ read: () => fs.readFileSync(h.full), source: h.rel, where: { kind: "file", file: h.full } });
|
||||
return {
|
||||
label: `extracted files in ${folder}`,
|
||||
find(base, sub, strict) {
|
||||
const h = pick(index.get(base) || [], (x) => x.lower, sub, strict);
|
||||
return h ? hitOf(h) : null;
|
||||
},
|
||||
bySuffix(suffix) {
|
||||
const s = "/" + suffix.toLowerCase();
|
||||
const h = (index.get(s.split("/").pop()) || []).find((x) => ("/" + x.lower).endsWith(s));
|
||||
return h ? hitOf(h) : null;
|
||||
},
|
||||
get(key) {
|
||||
const full = path.join(folder, key);
|
||||
return fs.existsSync(full) ? hitOf({ full, rel: key }) : null;
|
||||
},
|
||||
list: () => all.map((rel) => ({ key: rel, path: rel })),
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Best hit for a folder hint: resource/<folder>/<base>, then …/<folder>/…, then anything. */
|
||||
function pick(hits, lowerOf, folder, strict) {
|
||||
if (!hits.length) return null;
|
||||
if (!folder) return hits[0];
|
||||
const f = folder.toLowerCase();
|
||||
const dirOf = (h) => {
|
||||
const l = "/" + lowerOf(h);
|
||||
return l.slice(0, l.lastIndexOf("/") + 1);
|
||||
};
|
||||
const exact = hits.find((h) => dirOf(h).endsWith(`/resource/${f}/`));
|
||||
if (exact) return exact;
|
||||
const inFolder = hits.find((h) => dirOf(h).includes(`/${f}/`));
|
||||
if (inFolder) return inFolder;
|
||||
return strict ? null : hits[0];
|
||||
}
|
||||
|
||||
/** Passwords [type 0, type 1] of the first profile that opens a pack, or null for unencrypted packs. */
|
||||
function detectedPasswords(pack) {
|
||||
const d = tools.detectPassword(pack, store.keyOrder());
|
||||
if (!d || !d.encrypted) return null;
|
||||
return store.profile(d.profileId).passwords;
|
||||
}
|
||||
|
||||
const keyOf = (src) => `${src.kind}:${(src.folder || "").toLowerCase()}:${src.sessionId || ""}`;
|
||||
|
||||
function sourceFor(src) {
|
||||
if (!src) throw new Error("No source chosen.");
|
||||
const key = keyOf(src);
|
||||
let s = sources.get(key);
|
||||
if (s) return s;
|
||||
if (src.kind === "session") {
|
||||
const { password, file } = iop.handle(src.sessionId);
|
||||
const profileId = iop.sessions().find((x) => x.id === src.sessionId)?.profileId;
|
||||
const pws = store.profile(profileId)?.passwords;
|
||||
s = packSource(path.dirname(file), () => (password ? pws || [password, password] : null));
|
||||
} else if (src.kind === "packs") s = packSource(src.folder, detectedPasswords);
|
||||
else if (src.kind === "dir") s = dirSource(src.folder);
|
||||
else throw new Error("Unknown source");
|
||||
sources.set(key, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
const baseOf = (name) => name.replace(/\\/g, "/").split("/").pop().toLowerCase();
|
||||
const toBytes = (data) => new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
|
||||
/** Reads a resource by name. → { bytes, source, where } | null */
|
||||
function read(src, name, folder, strict = false) {
|
||||
const hit = sourceFor(src).find(baseOf(name), folder, strict);
|
||||
if (!hit) return null;
|
||||
return { bytes: toBytes(hit.read()), source: hit.source, where: hit.where };
|
||||
}
|
||||
|
||||
/** Reads a resource by the end of its path, e.g. "model/staticmodel/prisoner.txt". */
|
||||
function readPath(src, suffix) {
|
||||
const hit = sourceFor(src).bySuffix(suffix.replace(/\\/g, "/").replace(/^\/+/, ""));
|
||||
if (!hit) return null;
|
||||
return { bytes: toBytes(hit.read()), source: hit.source, where: hit.where };
|
||||
}
|
||||
|
||||
/** Reads a listed item by its key. */
|
||||
function readKey(src, key) {
|
||||
const hit = sourceFor(src).get(key);
|
||||
if (!hit) return null;
|
||||
return { bytes: toBytes(hit.read()), source: hit.source, where: hit.where };
|
||||
}
|
||||
|
||||
// Custom textures (.lsc) are stored scrambled; the client unscrambles them before D3DX loads them:
|
||||
// rotate each byte right by 3, XOR 0xFF, and (newer files) XOR an 8-byte salt. Salted is tried
|
||||
// first and kept when the result looks like an image (ioMemFile::DecompressLSCFile).
|
||||
const SALT = [0x1a, 0x3b, 0x5c, 0x7d, 0x9e, 0x2f, 0x41, 0x68];
|
||||
const sniff = (b) =>
|
||||
b[0] === 0x44 && b[1] === 0x44 && b[2] === 0x53 && b[3] === 0x20 ? "dds" : b[0] === 0x89 && b[1] === 0x50 ? "png" : b[0] === 0xff && b[1] === 0xd8 ? "jpg" : b[0] === 0x42 && b[1] === 0x4d ? "bmp" : null;
|
||||
function unscramble(src, salted) {
|
||||
const out = Buffer.allocUnsafe(src.length);
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
let c = src[i];
|
||||
c = (((c >> 3) | ((c & 7) << 5)) ^ 0xff) & 0xff;
|
||||
if (salted) c ^= SALT[i % 8];
|
||||
out[i] = c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A texture by its bare name. → { bytes, source, ext } | null */
|
||||
function texture(src, fileName) {
|
||||
const hit = sourceFor(src).find(baseOf(fileName), "texture", false);
|
||||
if (!hit) return null;
|
||||
let data = hit.read();
|
||||
const named = baseOf(fileName).split(".").pop();
|
||||
let ext = sniff(data);
|
||||
if (!ext && (named === "lsc" || named === "dds")) {
|
||||
const salted = unscramble(data, true);
|
||||
data = sniff(salted) ? salted : unscramble(data, false);
|
||||
ext = sniff(data) || named;
|
||||
}
|
||||
return { bytes: toBytes(data), source: hit.source, ext: ext || named };
|
||||
}
|
||||
|
||||
/** Names of every resource in the source, e.g. for the model browser. */
|
||||
function list(src) {
|
||||
return sourceFor(src).list();
|
||||
}
|
||||
|
||||
function describe(src) {
|
||||
return { label: sourceFor(src).label };
|
||||
}
|
||||
|
||||
/** Forgets a source's open packs / file index (after they changed). */
|
||||
function reload(src) {
|
||||
const key = keyOf(src);
|
||||
sources.get(key)?.close();
|
||||
sources.delete(key);
|
||||
}
|
||||
|
||||
/** Forgets every source that reads this pack file (after we wrote into it). */
|
||||
function forgetPack(file) {
|
||||
const dir = path.dirname(file).toLowerCase();
|
||||
for (const [key, s] of sources) {
|
||||
if (key.startsWith("dir:")) continue;
|
||||
if (s.label.toLowerCase().endsWith(dir) || key.startsWith("session:")) {
|
||||
s.close();
|
||||
sources.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a resource back where it came from: into its pack (as the newest copy of the entry,
|
||||
* comment and encryption kept; through IOP Manager when the pack is open there) or to its file.
|
||||
*/
|
||||
async function writeBack(where, data) {
|
||||
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
||||
if (where.kind === "file") {
|
||||
fs.writeFileSync(where.file, buf);
|
||||
return { file: where.file };
|
||||
}
|
||||
if (where.kind !== "pack") throw new Error("Unknown target");
|
||||
forgetPack(where.file);
|
||||
const open = iop.sessions().find((s) => s.file.toLowerCase() === where.file.toLowerCase());
|
||||
// where.create: a new entry is fine (Anim Studio's new animations)
|
||||
if (open) {
|
||||
await (where.create ? iop.addData : iop.replaceData)(open.id, where.entry, buf);
|
||||
return { file: where.file, entry: where.entry };
|
||||
}
|
||||
if (Pack.recover(where.file)) console.warn("[assets] rolled back an unfinished change of", where.file);
|
||||
const pack = Pack.open(where.file);
|
||||
try {
|
||||
const e = pack.lookup(where.entry);
|
||||
if (!e && !where.create) throw new Error(`${where.entry} isn't in ${path.basename(where.file)}.`);
|
||||
const d = tools.detectPassword(pack, store.keyOrder());
|
||||
const comment = e ? e.comment.toString("latin1") : "";
|
||||
const password = d?.encrypted ? store.profile(d.profileId).passwords[comment === "1" ? 1 : d.type ?? 0] : null;
|
||||
tools.addFile(pack, e ? e.name : where.entry, buf, { password, comment, mtime: new Date() });
|
||||
pack.commit();
|
||||
} finally {
|
||||
pack.close();
|
||||
}
|
||||
return { file: where.file, entry: where.entry };
|
||||
}
|
||||
|
||||
export { read, readPath, readKey, texture, list, describe, reload, writeBack };
|
||||
export default { read, readPath, readKey, texture, list, describe, reload, writeBack };
|
||||
@@ -0,0 +1,168 @@
|
||||
// Ported from LS Tools desktop: main/costumes.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import assets from "./assets.js";
|
||||
import items from "./items.js";
|
||||
import codec from "./lsmon/codec.js";
|
||||
import { IniDoc, TextDoc } from "./inidoc.js";
|
||||
|
||||
// Costume tab: registering a costume so the game can give and wear it. The client reads
|
||||
// config/sp2_costume.ini → config/costume/costume_{armor,helmet,cloak}.ini ([common_info]
|
||||
// costume_cnt, [costumeN] costum_code / name = STR(1) / icon_name / body_mesh …; the code's
|
||||
// 100 000s digit is the kind: 1 armor, 2 helmet, 3 cloak — ioCostumeInfoManager), the names come
|
||||
// from the string tables (|INI_costume_armor::costumeN_1|text|), and the game server only accepts
|
||||
// codes listed in its own config/sp2_costumemanager.ini ([costumeN] item_code — CostumeManager).
|
||||
// A player gets one as a present of type 12 (value1 = code, value2 = hours, 0 = for good).
|
||||
|
||||
const KINDS = {
|
||||
armor: { file: "costume/costume_armor.ini", type: 1, meshes: ["body_mesh", "hand_mesh"] },
|
||||
helmet: { file: "costume/costume_helmet.ini", type: 2, meshes: ["helmet_mesh"] },
|
||||
cloak: { file: "costume/costume_cloak.ini", type: 3, meshes: ["cloak_mesh"] },
|
||||
};
|
||||
const BASE = 100000;
|
||||
const num = (v) => {
|
||||
const n = parseInt(String(v ?? "").trim(), 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const stemOf = (kind) => KINDS[kind].file.split("/").pop().replace(/\.ini$/i, "").toLowerCase();
|
||||
|
||||
function readIni(src, kind) {
|
||||
const name = KINDS[kind].file;
|
||||
const hit = assets.readKey(src, `rs.iop|config/${name}`) || assets.readPath(src, `config/${name}`);
|
||||
if (!hit) throw new Error(`config/${name} isn't in these packs.`);
|
||||
return { doc: new IniDoc(Buffer.from(hit.bytes)), where: hit.where };
|
||||
}
|
||||
|
||||
/** The string tables with the code page their costume names are written in (HK: text.txt is Big5 too). */
|
||||
function texts(src, kind) {
|
||||
const out = [];
|
||||
for (const t of items.textFiles(src)) {
|
||||
const hit = assets.readKey(src, t.key);
|
||||
if (!hit) continue;
|
||||
const doc = new TextDoc(Buffer.from(hit.bytes));
|
||||
out.push({ ...t, doc, where: hit.where, encoding: guess(doc, kind, t.encoding) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function guess(doc, kind, fallback) {
|
||||
const prefix = `ini_${stemOf(kind)}::`;
|
||||
const parts = [];
|
||||
for (const [k] of doc.index) {
|
||||
if (!k.startsWith(prefix)) continue;
|
||||
const raw = doc.get(k);
|
||||
if (raw && /[\x80-\xff]/.test(raw)) parts.push(Buffer.from(raw, "latin1"));
|
||||
if (parts.length >= 80) break;
|
||||
}
|
||||
if (!parts.length) return fallback;
|
||||
const score = (enc, re) => {
|
||||
const text = parts.map((p) => codec.decodeText(p, enc)).join("");
|
||||
const wide = [...text].filter((ch) => ch.charCodeAt(0) >= 0x80);
|
||||
return wide.length ? wide.filter((ch) => re.test(ch)).length / wide.length - (text.match(/�/g) || []).length / wide.length : 0;
|
||||
};
|
||||
const kr = score("euc-kr", /[가-힣]/);
|
||||
const tw = score("big5", /[一-鿿]/);
|
||||
return kr >= 0.6 || kr >= tw - 0.05 ? "euc-kr" : "big5";
|
||||
}
|
||||
|
||||
/** The costumes of a kind: [{ section, code, name, icon, grade, meshes }] + the next free code. */
|
||||
function info(src, kind) {
|
||||
const k = KINDS[kind];
|
||||
if (!k) throw new Error(`Unknown costume kind ${kind}`);
|
||||
const { doc } = readIni(src, kind);
|
||||
const tx = texts(src, kind);
|
||||
const t = tx[0];
|
||||
const name = (section, raw) => {
|
||||
const m = /STR\(\s*(\d+)/i.exec(raw || "");
|
||||
if (m && t) {
|
||||
const v = t.doc.get(`ini_${stemOf(kind)}::${section}_${m[1]}`);
|
||||
if (v !== undefined) return codec.decodeText(Buffer.from(v, "latin1"), t.encoding);
|
||||
}
|
||||
return raw && /[\x80-\xff]/.test(raw) ? codec.decode(Buffer.from(raw, "latin1"), "euc-kr") : raw || "";
|
||||
};
|
||||
const count = num(doc.get("common_info", "costume_cnt"));
|
||||
const list = [];
|
||||
for (const s of doc.order) {
|
||||
const m = /^costume(\d+)$/i.exec(s.name);
|
||||
if (!m) continue;
|
||||
const code = num(doc.get(s.name, "costum_code"));
|
||||
if (!code) continue;
|
||||
const meshes = {};
|
||||
for (const key of k.meshes) meshes[key] = doc.get(s.name, key) || "";
|
||||
list.push({ section: s.name, n: Number(m[1]), code, name: name(s.name, doc.get(s.name, "name")), icon: doc.get(s.name, "icon_name") || "", grade: num(doc.get(s.name, "grade_type")), meshes, read: Number(m[1]) <= count });
|
||||
}
|
||||
const inRange = list.map((c) => c.code).filter((c) => Math.floor(c / BASE) === k.type);
|
||||
const next = (inRange.length ? Math.max(...inRange) : k.type * BASE + 10000) + 1;
|
||||
return { kind, file: k.file, count, list, next, texts: tx.map((x) => ({ file: x.file, encoding: x.encoding })) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a costume: { kind, code, name, grade, icon, meshes: { body_mesh, hand_mesh, … }, template
|
||||
* (section to copy the other keys from), serverFile (a copy of the server's sp2_costumemanager.ini
|
||||
* to add the code to; optional) } → { section, code, written: [...], server }
|
||||
*/
|
||||
async function register(src, e) {
|
||||
const k = KINDS[e.kind];
|
||||
if (!k) throw new Error(`Unknown costume kind ${e.kind}`);
|
||||
const code = num(e.code);
|
||||
if (Math.floor(code / BASE) !== k.type) throw new Error(`A ${e.kind} code starts with ${k.type} (${k.type}xxxxx); ${code} is another kind.`);
|
||||
if (!String(e.name || "").trim()) throw new Error("Give the costume a name.");
|
||||
const { doc, where } = readIni(src, e.kind);
|
||||
for (const s of doc.order) if (num(doc.get(s.name, "costum_code")) === code) throw new Error(`${code} is already [${s.name}] of ${k.file}.`);
|
||||
// the client reads [costume1] … [costume<costume_cnt>]: the new one goes right after
|
||||
let n = num(doc.get("common_info", "costume_cnt")) + 1;
|
||||
while (doc.has(`costume${n}`)) n++;
|
||||
const section = `costume${n}`;
|
||||
// e.extra: keys of the kind set here (a helmet: hair_change_hm / _hw, invisible_face), not copied
|
||||
const extra = Object.entries(e.extra || {});
|
||||
const skip = new Set(["name", "costum_code", "icon_name", "grade_type", "manual", "item_equip_effect", "item_equip_effect_hw", "spring_biped", ...k.meshes, ...extra.map(([key]) => key.toLowerCase())]);
|
||||
const copied = e.template && doc.has(e.template) ? doc.entries(e.template).filter(([key]) => !skip.has(key.toLowerCase())) : [];
|
||||
const entries = [
|
||||
["name", "STR(1)"],
|
||||
["costum_code", String(code)],
|
||||
["manual", "1"],
|
||||
["grade_type", e.grade ? String(e.grade) : null],
|
||||
["icon_name", e.icon || null],
|
||||
...copied,
|
||||
...extra,
|
||||
...k.meshes.map((key) => [key, e.meshes?.[key] || null]),
|
||||
];
|
||||
doc.addSection(section, entries);
|
||||
doc.update("common_info", { costume_cnt: String(n) });
|
||||
// the name, in every string table, in its own code page
|
||||
const warnings = [];
|
||||
const tx = texts(src, e.kind);
|
||||
if (!tx.length) warnings.push("No string table (resource/text) in these packs: the name shows as a key.");
|
||||
for (const t of tx) {
|
||||
const bytes = codec.encode(String(e.name).replace(/[\r\n|]/g, " "), t.encoding);
|
||||
if (codec.decode(bytes, t.encoding) !== String(e.name).replace(/[\r\n|]/g, " ")) warnings.push(`${t.file}: some characters don't exist in ${t.encoding} and became "?".`);
|
||||
t.doc.set(`${t.doc.iniPrefix()}${stemOf(e.kind)}::${section}_1`, bytes.toString("latin1"));
|
||||
}
|
||||
const written = [];
|
||||
await assets.writeBack(where, doc.toBuffer());
|
||||
written.push(`config/${k.file}`);
|
||||
for (const t of tx) {
|
||||
await assets.writeBack(t.where, t.doc.toBuffer());
|
||||
written.push(`resource/text/${t.file}`);
|
||||
}
|
||||
const server = e.serverFile ? addToServer(e.serverFile, code) : null;
|
||||
return { section, code, written, warnings, server, snippet: serverSnippet(code) };
|
||||
}
|
||||
|
||||
const serverSnippet = (code) => `[costumeN]\r\nitem_code = ${code}`;
|
||||
|
||||
/** Adds the code to a copy of the server's sp2_costumemanager.ini (a .bak is kept). */
|
||||
function addToServer(file, code) {
|
||||
const buf = fs.readFileSync(file);
|
||||
const doc = new IniDoc(buf);
|
||||
if (!doc.has("common")) throw new Error(`${file} has no [common]: is it sp2_costumemanager.ini?`);
|
||||
for (const s of doc.order) if (num(doc.get(s.name, "item_code")) === code) return { file, section: s.name, added: false };
|
||||
let n = num(doc.get("common", "costume_cnt")) + 1;
|
||||
while (doc.has(`costume${n}`)) n++;
|
||||
doc.addSection(`costume${n}`, [["item_code", String(code)]]);
|
||||
doc.update("common", { costume_cnt: String(n) });
|
||||
fs.writeFileSync(`${file}.bak`, buf);
|
||||
fs.writeFileSync(file, doc.toBuffer());
|
||||
return { file, section: `costume${n}`, added: true };
|
||||
}
|
||||
|
||||
export { info, register, addToServer, KINDS };
|
||||
export default { info, register, addToServer, KINDS };
|
||||
@@ -0,0 +1,444 @@
|
||||
// Ported from LS Tools desktop: main/dattable.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import assets from "./assets.js";
|
||||
import codec from "./lsmon/codec.js";
|
||||
import xlsx from "./xlsx.js";
|
||||
|
||||
// Tables tab: the game's .dat tables (made by ioDataMaker from Excel; client: resource/tables/*.dat in
|
||||
// the packs, loaded by BaseDataManager::LoadBin; server: ls_gamesvr/config/tables/*.dat). Layout:
|
||||
// int toolVersion, int headerVersion, int dataVersion, int columnCount,
|
||||
// per column: int size, int type, int nameLength, name bytes,
|
||||
// int rowCount, then rowCount packed rows (#pragma pack 1) of the columns in order.
|
||||
// headerVersion must match the one compiled into the client / server, so the columns can't be
|
||||
// changed here — rows and values can. Untouched cells are written back byte for byte.
|
||||
|
||||
const TYPES = {
|
||||
1: { name: "BYTE", read: (b, o) => b.readUInt8(o), write: (b, o, v) => b.writeUInt8(v, o), min: 0, max: 255 },
|
||||
2: { name: "WORD", read: (b, o) => b.readUInt16LE(o), write: (b, o, v) => b.writeUInt16LE(v, o), min: 0, max: 65535 },
|
||||
3: { name: "DWORD", read: (b, o) => b.readUInt32LE(o), write: (b, o, v) => b.writeUInt32LE(v, o), min: 0, max: 4294967295 },
|
||||
4: { name: "STRING" },
|
||||
5: { name: "SHORT", read: (b, o) => b.readInt16LE(o), write: (b, o, v) => b.writeInt16LE(v, o), min: -32768, max: 32767 },
|
||||
6: { name: "USHORT", read: (b, o) => b.readUInt16LE(o), write: (b, o, v) => b.writeUInt16LE(v, o), min: 0, max: 65535 },
|
||||
7: { name: "INT", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
|
||||
8: { name: "UINT", read: (b, o) => b.readUInt32LE(o), write: (b, o, v) => b.writeUInt32LE(v, o), min: 0, max: 4294967295 },
|
||||
9: { name: "LONG", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
|
||||
10: { name: "ULONG", read: (b, o) => b.readUInt32LE(o), write: (b, o, v) => b.writeUInt32LE(v, o), min: 0, max: 4294967295 },
|
||||
11: { name: "FLOAT", float: true, read: (b, o) => b.readFloatLE(o), write: (b, o, v) => b.writeFloatLE(v, o) },
|
||||
13: { name: "ENUM", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
|
||||
14: { name: "BOOL", read: (b, o) => b.readUInt8(o), write: (b, o, v) => b.writeUInt8(v, o), min: 0, max: 255 },
|
||||
};
|
||||
// a type number not seen yet: read by its size
|
||||
const BY_SIZE = {
|
||||
1: { name: "BYTE?", read: (b, o) => b.readUInt8(o), write: (b, o, v) => b.writeUInt8(v, o), min: 0, max: 255 },
|
||||
2: { name: "WORD?", read: (b, o) => b.readUInt16LE(o), write: (b, o, v) => b.writeUInt16LE(v, o), min: 0, max: 65535 },
|
||||
4: { name: "INT?", read: (b, o) => b.readInt32LE(o), write: (b, o, v) => b.writeInt32LE(v, o), min: -2147483648, max: 2147483647 },
|
||||
8: { name: "DOUBLE?", float: true, read: (b, o) => b.readDoubleLE(o), write: (b, o, v) => b.writeDoubleLE(v, o) },
|
||||
};
|
||||
const typeOf = (c) => (c.type === 4 ? TYPES[4] : TYPES[c.type] && (c.type !== 11 || c.size === 4) ? TYPES[c.type] : BY_SIZE[c.size] || null);
|
||||
const fmtFloat = (v) => String(Number(v.toPrecision(7)));
|
||||
|
||||
function parse(buf) {
|
||||
let o = 0;
|
||||
const need = (n) => {
|
||||
if (o + n > buf.length) throw new Error("This .dat file is cut short.");
|
||||
};
|
||||
const i32 = () => (need(4), (o += 4), buf.readInt32LE(o - 4));
|
||||
const tool = i32();
|
||||
const headerVersion = i32();
|
||||
const dataVersion = i32();
|
||||
const count = i32();
|
||||
if (count <= 0 || count > 1000) throw new Error("Not a table .dat (column count).");
|
||||
const cols = [];
|
||||
let offset = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size = i32();
|
||||
const type = i32();
|
||||
const nameLength = i32();
|
||||
need(nameLength);
|
||||
const name = buf.toString("latin1", o, o + nameLength).replace(/\0[\s\S]*$/, "");
|
||||
o += nameLength;
|
||||
if (size <= 0 || size > 65536) throw new Error(`Column ${name}: bad size ${size}.`);
|
||||
const t = typeOf({ type, size });
|
||||
cols.push({ name, type, size, offset, typeName: type === 4 ? `STRING[${size}]` : t?.name || `TYPE ${type}`, float: !!t?.float, text: type === 4, known: !!t });
|
||||
offset += size;
|
||||
}
|
||||
const rowSize = offset;
|
||||
const totalAt = o;
|
||||
const total = i32();
|
||||
if (total < 0 || totalAt + 4 + total * rowSize > buf.length) throw new Error(`Row data is shorter than ${total} rows of ${rowSize} bytes.`);
|
||||
const dataAt = o;
|
||||
const trailing = buf.length - (dataAt + total * rowSize);
|
||||
return { tool, headerVersion, dataVersion, cols, rowSize, total, head: buf.subarray(0, totalAt), dataAt, trailing: trailing ? buf.subarray(dataAt + total * rowSize) : null, buf };
|
||||
}
|
||||
|
||||
function cellText(t, row, c, encoding) {
|
||||
const at = t.dataAt + row * t.rowSize + c.offset;
|
||||
if (c.text) {
|
||||
const raw = t.buf.subarray(at, at + c.size);
|
||||
const end = raw.indexOf(0);
|
||||
return codec.decode(raw.subarray(0, end < 0 ? raw.length : end), encoding);
|
||||
}
|
||||
const ty = typeOf(c);
|
||||
if (!ty) return t.buf.subarray(at, at + c.size).toString("hex");
|
||||
const v = ty.read(t.buf, at);
|
||||
return ty.float ? fmtFloat(v) : String(v);
|
||||
}
|
||||
|
||||
/** Values as strings: [[…], …] */
|
||||
function rowsOf(t, encoding) {
|
||||
const out = new Array(t.total);
|
||||
for (let r = 0; r < t.total; r++) out[r] = t.cols.map((c) => cellText(t, r, c, encoding));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* rows: [{ o: original row index or -1, v: [value strings] }] → bytes, or throws with the list of
|
||||
* bad cells. A cell equal to its original text keeps its original bytes.
|
||||
*/
|
||||
function build(t, rows, encoding, enumLookup = () => null) {
|
||||
const errors = [];
|
||||
const out = Buffer.alloc(t.head.length + 4 + rows.length * t.rowSize + (t.trailing?.length || 0));
|
||||
t.head.copy(out, 0);
|
||||
out.writeInt32LE(rows.length, t.head.length);
|
||||
const base = t.head.length + 4;
|
||||
rows.forEach((row, r) => {
|
||||
const at = base + r * t.rowSize;
|
||||
t.cols.forEach((c, ci) => {
|
||||
const value = String(row.v[ci] ?? "");
|
||||
const dst = at + c.offset;
|
||||
if (row.o >= 0 && row.o < t.total && cellText(t, row.o, c, encoding) === value) {
|
||||
const src = t.dataAt + row.o * t.rowSize + c.offset;
|
||||
t.buf.copy(out, dst, src, src + c.size);
|
||||
return;
|
||||
}
|
||||
if (c.text) {
|
||||
const bytes = codec.encode(value, encoding);
|
||||
if (codec.decode(bytes, encoding) !== value) errors.push({ r, c: ci, msg: `has characters ${encoding} can't hold` });
|
||||
if (bytes.length > c.size - 1) errors.push({ r, c: ci, msg: `is ${bytes.length} bytes; ${c.name} holds ${c.size - 1}` });
|
||||
bytes.copy(out, dst, 0, Math.min(bytes.length, c.size - 1));
|
||||
return;
|
||||
}
|
||||
const ty = typeOf(c);
|
||||
if (!ty) {
|
||||
const hex = Buffer.from(value, "hex");
|
||||
if (hex.length !== c.size) errors.push({ r, c: ci, msg: `must be ${c.size} bytes of hex` });
|
||||
hex.copy(out, dst, 0, c.size);
|
||||
return;
|
||||
}
|
||||
let v;
|
||||
if (ty.float) v = Number(value || 0);
|
||||
else if (/^-?\d+$/.test(value.trim() || "0")) v = Number(value.trim() || 0);
|
||||
else if (c.type === 13 && enumLookup(c.name, value.trim()) !== null) v = enumLookup(c.name, value.trim());
|
||||
else if (/^(true|false)$/i.test(value.trim())) v = /^true$/i.test(value.trim()) ? 1 : 0;
|
||||
else v = NaN;
|
||||
if (!Number.isFinite(v)) {
|
||||
errors.push({ r, c: ci, msg: `"${value}" isn't a number` });
|
||||
return;
|
||||
}
|
||||
if (!ty.float && (v < ty.min || v > ty.max)) {
|
||||
errors.push({ r, c: ci, msg: `${v} is outside ${ty.name} (${ty.min} … ${ty.max})` });
|
||||
return;
|
||||
}
|
||||
ty.write(out, dst, v);
|
||||
});
|
||||
});
|
||||
if (t.trailing) t.trailing.copy(out, base + rows.length * t.rowSize);
|
||||
if (errors.length) {
|
||||
const e = new Error(`${errors.length} cell(s) can't be saved: ${errors.slice(0, 5).map((x) => `row ${x.r + 1} ${t.cols[x.c].name} ${x.msg}`).join("; ")}${errors.length > 5 ? " …" : ""}`);
|
||||
e.cells = errors;
|
||||
throw e;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The code page of a table's texts: Korean (the ioDataMaker sheets are Korean) unless the bytes read
|
||||
* better as the region's own (e.g. Big5 for HK). Picks by how many decoded characters are Hangul / CJK.
|
||||
*/
|
||||
function guessEncoding(t, fallback = "euc-kr") {
|
||||
const parts = [];
|
||||
for (let r = 0; r < Math.min(t.total, 400); r++)
|
||||
for (const c of t.cols) if (c.text) {
|
||||
const at = t.dataAt + r * t.rowSize + c.offset;
|
||||
const raw = t.buf.subarray(at, at + c.size);
|
||||
const end = raw.indexOf(0);
|
||||
const s = raw.subarray(0, end < 0 ? raw.length : end);
|
||||
if (s.some((b) => b >= 0x80)) parts.push(s);
|
||||
}
|
||||
if (!parts.length) return fallback;
|
||||
const score = (enc, re) => {
|
||||
const text = parts.map((p) => codec.decode(p, enc)).join("");
|
||||
const wide = [...text].filter((ch) => ch.charCodeAt(0) >= 0x80);
|
||||
return wide.length ? wide.filter((ch) => re.test(ch)).length / wide.length - (text.match(/�/g) || []).length / wide.length : 0;
|
||||
};
|
||||
const kr = score("euc-kr", /[가-힣]/);
|
||||
const tw = score("big5", /[一-鿿]/);
|
||||
// Korean read as Big5 gives rare hanzi too: Korean wins unless it scores clearly worse
|
||||
return kr >= 0.6 || kr >= tw - 0.05 ? "euc-kr" : "big5";
|
||||
}
|
||||
|
||||
// ---------- where tables are ----------
|
||||
/** Client tables in a pack source: [{ key, name, path, pack }] */
|
||||
function clientTables(src) {
|
||||
return assets
|
||||
.list(src)
|
||||
.filter((it) => /(^|\/)tables\/[^/]+\.dat$/i.test(it.path))
|
||||
.map((it) => ({ key: it.key, name: path.basename(it.path), path: it.path, pack: it.pack || "" }));
|
||||
}
|
||||
|
||||
function folderTables(folder) {
|
||||
if (!folder || !fs.existsSync(folder)) return [];
|
||||
return fs
|
||||
.readdirSync(folder)
|
||||
.filter((f) => /\.dat$/i.test(f) && fs.statSync(path.join(folder, f)).isFile())
|
||||
.map((f) => ({ key: path.join(folder, f), name: f, path: path.join(folder, f) }));
|
||||
}
|
||||
|
||||
// ---------- open tables ----------
|
||||
const open = new Map(); // id → { where, label, t, encoding, name }
|
||||
let seq = 0;
|
||||
|
||||
function load(ref, encoding) {
|
||||
let buf;
|
||||
let where;
|
||||
let label;
|
||||
if (ref.kind === "pack") {
|
||||
const hit = assets.readKey(ref.src, ref.key);
|
||||
if (!hit) throw new Error(`${ref.key} isn't in the packs any more.`);
|
||||
buf = Buffer.from(hit.bytes);
|
||||
where = hit.where;
|
||||
label = `${ref.key.split("|")[0]} → ${ref.key.split("|")[1]}`;
|
||||
} else {
|
||||
buf = fs.readFileSync(ref.file);
|
||||
where = { kind: "file", file: ref.file };
|
||||
label = ref.file;
|
||||
}
|
||||
const t = parse(buf);
|
||||
if (!encoding || encoding === "auto") encoding = guessEncoding(t);
|
||||
const id = ++seq;
|
||||
const name = path.basename(ref.kind === "pack" ? ref.key.split("|")[1] : ref.file).replace(/\.dat$/i, "");
|
||||
open.set(id, { where, label, t, encoding, name, ref });
|
||||
return { id, ...describe(id) };
|
||||
}
|
||||
|
||||
function describe(id) {
|
||||
const x = open.get(id);
|
||||
if (!x) throw new Error("That table was closed.");
|
||||
const { t } = x;
|
||||
return {
|
||||
name: x.name,
|
||||
label: x.label,
|
||||
encoding: x.encoding,
|
||||
tool: t.tool,
|
||||
headerVersion: t.headerVersion.toString(16),
|
||||
dataVersion: t.dataVersion,
|
||||
rowSize: t.rowSize,
|
||||
cols: t.cols.map(({ name, type, size, typeName, float, text, known }) => ({ name, type, size, typeName, float, text, known })),
|
||||
rows: rowsOf(t, x.encoding),
|
||||
};
|
||||
}
|
||||
|
||||
function setEncoding(id, encoding) {
|
||||
open.get(id).encoding = encoding;
|
||||
return describe(id);
|
||||
}
|
||||
|
||||
async function save(id, rows, { enums = {} } = {}) {
|
||||
const x = open.get(id);
|
||||
if (!x) throw new Error("That table was closed.");
|
||||
const lookup = (col, name) => {
|
||||
const list = enums[col];
|
||||
const hit = list?.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
||||
return hit ? hit.value : null;
|
||||
};
|
||||
const bytes = build(x.t, rows, x.encoding, lookup);
|
||||
if (x.where.kind === "file") {
|
||||
// one backup of the file as it was before this tool first changed it in this session
|
||||
const bak = `${x.where.file}.bak`;
|
||||
if (!x.backedUp) {
|
||||
fs.copyFileSync(x.where.file, bak);
|
||||
x.backedUp = bak;
|
||||
}
|
||||
fs.writeFileSync(x.where.file, bytes);
|
||||
} else {
|
||||
await assets.writeBack(x.where, bytes);
|
||||
}
|
||||
// what's saved is the new original
|
||||
x.t = parse(bytes);
|
||||
return { bytes: bytes.length, rows: rows.length, backup: x.backedUp || null, ...describe(id) };
|
||||
}
|
||||
|
||||
function close(id) {
|
||||
open.delete(id);
|
||||
}
|
||||
|
||||
// ---------- Excel / CSV ----------
|
||||
function exportXlsx(id, rows, file) {
|
||||
const x = open.get(id);
|
||||
const cols = x.t.cols;
|
||||
const sheet = {
|
||||
name: x.name,
|
||||
rows: [cols.map((c) => c.name), ...rows.map((r) => r.map((v, i) => (!cols[i].text && v !== "" && Number.isFinite(Number(v)) ? Number(v) : v)))],
|
||||
};
|
||||
const types = { name: "Columns", rows: [["Column", "Type", "Bytes"], ...cols.map((c) => [c.name, c.typeName, c.size])] };
|
||||
fs.writeFileSync(file, xlsx.writeXlsx([sheet, types]));
|
||||
return file;
|
||||
}
|
||||
|
||||
/** A sheet / CSV whose first row names the columns → rows in this table's column order. */
|
||||
function importFile(id, file) {
|
||||
const x = open.get(id);
|
||||
let grid;
|
||||
if (/\.xlsx$/i.test(file)) {
|
||||
const sheets = xlsx.readXlsx(fs.readFileSync(file));
|
||||
const pick = sheets.find((s) => s.name.toLowerCase() === x.name.toLowerCase()) || sheets[0];
|
||||
grid = pick.rows;
|
||||
// an ioDataMaker source sheet: rows EXPORT / OPTION / TYPE / HEAD, then data (";" rows skipped)
|
||||
const head = grid.findIndex((r) => String(r[0]).toUpperCase() === "HEAD");
|
||||
if (head >= 0) {
|
||||
const names = grid[head].slice(1);
|
||||
const data = grid.slice(head + 1).filter((r) => r.length && String(r[0]).trim() !== ";" && !/^(END|REMARK)$/i.test(String(r[0]).trim()));
|
||||
const end = data.findIndex((r) => /^END$/i.test(String(r[0] || r[1] || "").trim()));
|
||||
grid = [names, ...(end >= 0 ? data.slice(0, end) : data).map((r) => r.slice(1))];
|
||||
}
|
||||
} else {
|
||||
const text = fs.readFileSync(file, "utf8").replace(/^/, "");
|
||||
grid = parseCsv(text, /\.tsv$|\.txt$/i.test(file) ? "\t" : text.split("\n")[0].includes("\t") ? "\t" : ",");
|
||||
}
|
||||
if (!grid.length) throw new Error("The file is empty.");
|
||||
const names = grid[0].map((n) => String(n).trim().toLowerCase());
|
||||
const map = x.t.cols.map((c) => names.indexOf(c.name.toLowerCase()));
|
||||
const missing = x.t.cols.filter((_, i) => map[i] < 0).map((c) => c.name);
|
||||
const rows = grid
|
||||
.slice(1)
|
||||
.filter((r) => r.some((v) => String(v).trim() !== ""))
|
||||
.map((r) => map.map((i) => (i < 0 ? "" : String(r[i] ?? ""))));
|
||||
return { rows, missing, extra: grid[0].filter((n) => !x.t.cols.some((c) => c.name.toLowerCase() === String(n).trim().toLowerCase())) };
|
||||
}
|
||||
|
||||
function parseCsv(text, sep) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let cell = "";
|
||||
let q = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (q) {
|
||||
if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++;
|
||||
else if (ch === '"') q = false;
|
||||
else cell += ch;
|
||||
} else if (ch === '"' && cell === "") q = true;
|
||||
else if (ch === sep) row.push(cell), (cell = "");
|
||||
else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = "");
|
||||
else cell += ch;
|
||||
}
|
||||
if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function exportCsv(id, rows, file) {
|
||||
const x = open.get(id);
|
||||
const esc = (v) => (/[",\n\r]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
|
||||
const text = [x.t.cols.map((c) => c.name), ...rows].map((r) => r.map(esc).join(",")).join("\r\n");
|
||||
fs.writeFileSync(file, "" + text, "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
// ---------- web addition: export for the client or the server ----------
|
||||
/**
|
||||
* Client and server .dat of the same table have different columns (ioDataMaker exports some only to
|
||||
* one side) and their own header version. This writes the open table's rows in the layout of
|
||||
* `target` (that side's .dat of the same table, used as the template): columns are matched by name;
|
||||
* a column the open table doesn't have takes the template row with the same key (first column), or
|
||||
* 0 / empty. Unchanged cells stay byte-identical to the template. → the file is written to `file`.
|
||||
*/
|
||||
function exportFor(id, rows, target, file) {
|
||||
const x = open.get(id);
|
||||
if (!x) throw new Error("That table was closed.");
|
||||
let buf;
|
||||
if (target.kind === "pack") {
|
||||
const hit = assets.readKey(target.src, target.key);
|
||||
if (!hit) throw new Error(`${target.key} isn't in the packs any more.`);
|
||||
buf = Buffer.from(hit.bytes);
|
||||
} else buf = fs.readFileSync(target.file);
|
||||
const t2 = parse(buf);
|
||||
const enc = guessEncoding(t2, x.encoding);
|
||||
const lower = (s) => s.toLowerCase();
|
||||
const from = t2.cols.map((c) => x.t.cols.findIndex((s) => lower(s.name) === lower(c.name)));
|
||||
const missing = t2.cols.filter((_, i) => from[i] < 0).map((c) => c.name);
|
||||
const dropped = x.t.cols.filter((c) => !t2.cols.some((d) => lower(d.name) === lower(c.name))).map((c) => c.name);
|
||||
// template rows by key (first column), when both tables share it
|
||||
const keyFrom = from[0];
|
||||
const tplRows = rowsOf(t2, enc);
|
||||
const byKey = new Map();
|
||||
if (keyFrom >= 0) tplRows.forEach((r, i) => !byKey.has(r[0]) && byKey.set(r[0], i));
|
||||
const out = rows.map((v) => {
|
||||
const o = keyFrom >= 0 && byKey.has(String(v[keyFrom])) ? byKey.get(String(v[keyFrom])) : -1;
|
||||
return {
|
||||
o,
|
||||
v: t2.cols.map((c, i) => (from[i] >= 0 ? String(v[from[i]] ?? "") : o >= 0 ? tplRows[o][i] : c.text ? "" : "0")),
|
||||
};
|
||||
});
|
||||
const bytes = build(t2, out, enc);
|
||||
fs.writeFileSync(file, bytes);
|
||||
return { file, rows: out.length, missing, dropped, headerVersion: t2.headerVersion.toString(16), bytes: bytes.length };
|
||||
}
|
||||
|
||||
// ---------- ioDataMaker sources: column descriptions and enum names ----------
|
||||
const metaCache = new Map();
|
||||
/** From ioDataMaker's Config.xlsx and the table's source sheet: { cols: { name: { desc, export, enumName } }, enums: { col: [{ name, value }] } } */
|
||||
function meta(folder, table) {
|
||||
if (!folder) return null;
|
||||
const cfgFile = path.join(folder, "Config.xlsx");
|
||||
if (!fs.existsSync(cfgFile)) return null;
|
||||
const key = `${folder}|${table}`.toLowerCase();
|
||||
const stamp = fs.statSync(cfgFile).mtimeMs;
|
||||
const hit = metaCache.get(key);
|
||||
if (hit && hit.stamp === stamp) return hit.value;
|
||||
const cfg = xlsx.readXlsx(fs.readFileSync(cfgFile));
|
||||
const enums = {};
|
||||
let cur = null;
|
||||
for (const r of cfg.find((s) => s.name.toLowerCase() === "enum")?.rows || []) {
|
||||
const kind = String(r[1] || "").trim().toUpperCase();
|
||||
if (kind === "NAME") (cur = String(r[2] || "").trim()), (enums[cur] = []);
|
||||
else if (kind === "VAL" && cur) enums[cur].push({ name: String(r[2] || "").trim(), value: Number(r[3]) });
|
||||
else if (kind === "END") cur = null;
|
||||
}
|
||||
const sheets = new Map();
|
||||
for (const r of cfg.find((s) => s.name.toLowerCase() === "config")?.rows || []) {
|
||||
if (String(r[0] || "").trim() === ";" || !r[1] || !r[2]) continue;
|
||||
sheets.set(String(r[2]).trim().toLowerCase(), path.join(folder, String(r[1]).replace(/^\.[\\/]/, "")));
|
||||
}
|
||||
const value = { cols: {}, enums: {}, source: null };
|
||||
const src = sheets.get(table.toLowerCase());
|
||||
if (src && fs.existsSync(src)) {
|
||||
const book = xlsx.readXlsx(fs.readFileSync(src));
|
||||
const sh = book.find((s) => s.name.toLowerCase() === table.toLowerCase());
|
||||
if (sh) {
|
||||
value.source = `${path.basename(src)} [${sh.name}]`;
|
||||
const find = (label) => sh.rows.find((r) => String(r[0] || "").trim().toUpperCase() === label) || [];
|
||||
const exp = find("EXPORT");
|
||||
const types = find("TYPE");
|
||||
const head = find("HEAD");
|
||||
const descRow = sh.rows[sh.rows.indexOf(head) + 1] || [];
|
||||
const desc = String(descRow[0] || "").trim() === ";" ? descRow : [];
|
||||
head.forEach((n, i) => {
|
||||
if (!i || !n) return;
|
||||
const name = String(n).trim();
|
||||
const ty = String(types[i] || "");
|
||||
const m = /^ENUM\[([^\]]+)\]/i.exec(ty);
|
||||
value.cols[name] = { desc: String(desc[i] || "").trim(), export: String(exp[i] || "").trim(), enumName: m ? m[1] : null };
|
||||
if (m) {
|
||||
const list = enums[m[1]] || enums[`DATA_${m[1]}`] || Object.entries(enums).find(([k]) => k.toLowerCase().endsWith(m[1].toLowerCase()))?.[1];
|
||||
if (list) value.enums[name] = list;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
metaCache.set(key, { stamp, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
export { parse, rowsOf, build, clientTables, folderTables, load, describe, setEncoding, save, close, exportXlsx, exportCsv, importFile, meta, exportFor, TYPES };
|
||||
export default { parse, rowsOf, build, clientTables, folderTables, load, describe, setEncoding, save, close, exportXlsx, exportCsv, importFile, meta, exportFor, TYPES };
|
||||
@@ -0,0 +1,296 @@
|
||||
// Generated by tools/gen-handlers.mjs from the desktop main/index.js registerIpc(). Do not edit.
|
||||
// Dropped channels: 118
|
||||
import __req___imagesets from "./imagesets.js";
|
||||
import __req_node_fs from "node:fs";
|
||||
import __req___assets from "./assets.js";
|
||||
import __req___items from "./items.js";
|
||||
import __req___costumes from "./costumes.js";
|
||||
import __req___quests from "./quests.js";
|
||||
import __req___dattable from "./dattable.js";
|
||||
import __req___models from "./models.js";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import store from "./store.js";
|
||||
import iop from "./iopman.js";
|
||||
|
||||
const tasks = new Map();
|
||||
const win = null;
|
||||
|
||||
export function register(handle, task) {
|
||||
handle("task:cancel", (taskId) => tasks.get(taskId)?.abort());
|
||||
|
||||
const ims = __req___imagesets;
|
||||
|
||||
handle("ims:loadPack", (sessionId, entry) => ims.loadFromPack(sessionId, entry));
|
||||
|
||||
handle("ims:loadFile", (file) => ims.loadFromFile(file));
|
||||
|
||||
handle("ims:texture", (source, file) => ims.texture(source, file));
|
||||
|
||||
handle("ims:describeSource", (source) => ims.describe(source));
|
||||
|
||||
handle("ims:savePack", (sessionId, entry, text) => ims.saveToPack(sessionId, entry, text));
|
||||
|
||||
handle("ims:saveFile", (file, text) => ims.saveToFile(file, text));
|
||||
|
||||
handle("ims:reloadTextures", (source) => ims.reloadTextures(source));
|
||||
|
||||
handle("file:writeBase64", (file, b64) => __req_node_fs.writeFileSync(file, Buffer.from(b64, "base64")));
|
||||
|
||||
const assets = __req___assets;
|
||||
|
||||
const fs = __req_node_fs;
|
||||
|
||||
handle("mdl:list", (src) => {
|
||||
// [key, path, pack] of every model config, mesh, skeleton and animation
|
||||
const out = [];
|
||||
for (const it of assets.list(src)) {
|
||||
const l = it.path.toLowerCase();
|
||||
if (/\.(msh|ani|skl)$/.test(l) || (l.endsWith(".txt") && /(^|\/)model\//.test(l))) out.push([it.key, it.path, it.pack || ""]);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
handle("fx:list", (src) => assets.list(src)
|
||||
.filter((it) => /(^|\/)resource\/effect\/.+\.txt$/i.test(it.path))
|
||||
.map((it) => [it.key, it.path, it.pack || ""]));
|
||||
|
||||
handle("hero:list", (src) => {
|
||||
const rows = assets.list(src).filter((it) => /(^|\/)config\/mercenary\/(\d{3})\/\2_item\.ini$/i.test(it.path));
|
||||
return rows.map((it) => {
|
||||
const id = /mercenary\/(\d{3})\//i.exec(it.path)[1];
|
||||
const hit = assets.readKey(src, it.key);
|
||||
const raw = hit ? Buffer.from(hit.bytes).toString("latin1") : "";
|
||||
const name = /;[-\s/]*\d{3}\.\s*([^\r\n;]+)/.exec(raw)?.[1]?.trim()
|
||||
|| /\[item1\][\s\S]*?^\s*name\s*=\s*([^\r\n;]+)/im.exec(raw)?.[1]?.trim()
|
||||
|| `Hero ${id}`;
|
||||
return [id, name, it.pack || ""];
|
||||
}).sort((a, b) => Number(a[0]) - Number(b[0]));
|
||||
});
|
||||
|
||||
handle("hero:export", (folder, id, files) => {
|
||||
if (!/^\d{3}$/.test(id) || !Array.isArray(files) || !files.length) throw new Error("No hero changes to export");
|
||||
const allowed = new Set(["item", "skill", "attack", "buff"]);
|
||||
for (const f of files) if (!allowed.has(f.kind) || typeof f.text !== "string") throw new Error("Invalid hero export file");
|
||||
const root = fs.mkdtempSync(path.join(folder, `hero-${id}-`));
|
||||
const dir = path.join(root, "config", "mercenary", id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of files) fs.writeFileSync(path.join(dir, `${id}_${f.kind}.ini`), Buffer.from(f.text, "latin1"));
|
||||
fs.writeFileSync(path.join(root, "README.txt"),
|
||||
`Hero Creator experiment for mercenary ${id}\r\n\r\n` +
|
||||
`Edited configs are under config/mercenary/${id}/.\r\n` +
|
||||
`Inspect the changes and test on a disposable client copy before packing.\r\n` +
|
||||
`No game pack was changed by the export.\r\n`);
|
||||
return { folder: root, files: files.map((f) => `${id}_${f.kind}.ini`) };
|
||||
});
|
||||
|
||||
const fxlabDir = () => path.join(app.getPath("userData"), "fxlab-presets");
|
||||
|
||||
const presetFile = (name) => {
|
||||
const safe = String(name || "").replace(/[^\w\- .]/g, "_").trim().slice(0, 80);
|
||||
if (!safe) throw new Error("Preset needs a name");
|
||||
return path.join(fxlabDir(), `${safe}.json`);
|
||||
};
|
||||
|
||||
handle("fxlab:presets", () => {
|
||||
if (!fs.existsSync(fxlabDir())) return [];
|
||||
return fs.readdirSync(fxlabDir()).filter((f) => f.toLowerCase().endsWith(".json")).sort().flatMap((f) => {
|
||||
try { return [JSON.parse(fs.readFileSync(path.join(fxlabDir(), f), "utf8"))]; } catch { return []; }
|
||||
});
|
||||
});
|
||||
|
||||
handle("fxlab:savePreset", (preset) => {
|
||||
fs.mkdirSync(fxlabDir(), { recursive: true });
|
||||
const file = presetFile(preset?.name);
|
||||
fs.writeFileSync(file, JSON.stringify(preset, null, 2));
|
||||
return file;
|
||||
});
|
||||
|
||||
handle("fxlab:deletePreset", (name) => { const f = presetFile(name); if (fs.existsSync(f)) fs.unlinkSync(f); });
|
||||
|
||||
handle("fxlab:writeFiles", (folder, files) => {
|
||||
const root = path.resolve(folder);
|
||||
const out = [];
|
||||
for (const f of files || []) {
|
||||
const dest = path.resolve(root, f.path);
|
||||
if (!dest.startsWith(root + path.sep)) throw new Error(`Invalid export path ${f.path}`);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.from(f.b64, "base64"));
|
||||
out.push(dest);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
handle("mdl:read", (src, name, folder, strict) => assets.read(src, name, folder, strict));
|
||||
|
||||
handle("mdl:readKey", (src, key) => assets.readKey(src, key));
|
||||
|
||||
handle("mdl:readPath", (src, suffix) => assets.readPath(src, suffix));
|
||||
|
||||
handle("wnd:texts", (src) => {
|
||||
const hits = assets.list(src).filter((it) => /(^|\/)text\/text\.txt$/i.test(it.path));
|
||||
let best = null;
|
||||
for (const it of hits) {
|
||||
const r = assets.readKey(src, it.key);
|
||||
if (!r) continue;
|
||||
const b = Buffer.from(r.bytes.buffer, r.bytes.byteOffset, r.bytes.byteLength);
|
||||
const s = b.toString("latin1");
|
||||
const n = (s.match(/\|xml_/gi) || []).length;
|
||||
if (!best || n > best.n) best = { n, b, source: r.source };
|
||||
}
|
||||
if (!best) return null;
|
||||
// XML lines only (the file also holds every EXE_/INI_ string)
|
||||
const lines = best.b.toString("latin1").split(/\r?\n/).filter((l) => /^\|xml_/i.test(l));
|
||||
const sample = Buffer.from(lines.slice(0, 4000).join("\n"), "latin1");
|
||||
let enc = "big5";
|
||||
let fewest = Infinity;
|
||||
for (const e of ["big5", "euc-kr", "gbk", "windows-874", "shift_jis"]) {
|
||||
const bad = (new TextDecoder(e).decode(sample).match(/�/g) || []).length;
|
||||
if (bad < fewest) {
|
||||
fewest = bad;
|
||||
enc = e;
|
||||
}
|
||||
}
|
||||
const dec = new TextDecoder(enc);
|
||||
const xml = {};
|
||||
for (const l of lines) {
|
||||
const m = /^\|([^|]+)\|(.*)\|\s*$/.exec(l);
|
||||
if (m) xml[m[1].toLowerCase()] = dec.decode(Buffer.from(m[2], "latin1")).replace(/\\n/g, "\n").replace(/\\r/g, "");
|
||||
}
|
||||
return { source: best.source, encoding: enc, xml, count: Object.keys(xml).length };
|
||||
});
|
||||
|
||||
handle("wnd:font", (src) => assets.readPath(src, "font/lostsaga.ttf"));
|
||||
|
||||
handle("xml:list", (src) => assets.list(src).filter((it) => /(^|\/)xml\/.+\.xml$/i.test(it.path)).map((it) => [it.key, it.path, it.pack || ""]));
|
||||
|
||||
handle("mdl:names", (src, folder) => {
|
||||
const re = new RegExp(`(^|/)resource/${folder.replace(/[^\w-]/g, "")}/(.+)$`, "i");
|
||||
const out = new Set();
|
||||
for (const it of assets.list(src)) {
|
||||
const m = re.exec(it.path);
|
||||
if (m && m[2].includes(".")) out.add(m[2]);
|
||||
}
|
||||
return [...out].sort();
|
||||
});
|
||||
|
||||
handle("map:list", (src) => {
|
||||
const out = [];
|
||||
for (const it of assets.list(src)) {
|
||||
const l = it.path.toLowerCase();
|
||||
const m = /(^|\/)config\/mode\/(.+)\.ini$/.exec(l);
|
||||
if (!m) continue;
|
||||
let text;
|
||||
try {
|
||||
text = Buffer.from(assets.readKey(src, it.key).bytes).toString("latin1");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const mesh = /^\s*\[map\][^[]*?^\s*mesh_name1?\s*=\s*([^\r\n;]+)/im.exec(text);
|
||||
if (!mesh) continue;
|
||||
const parts = m[2].split("/");
|
||||
out.push([it.key, it.path, parts.length > 1 ? parts[0] : "", mesh[1].trim(), it.pack || ""]);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
handle("items:load", (src, opts) => __req___items.load(src, opts || {}));
|
||||
|
||||
handle("items:detail", (src, textFile, categoryId, index) => __req___items.detail(src, textFile, categoryId, index));
|
||||
|
||||
handle("cos:info", (src, kind) => __req___costumes.info(src, kind));
|
||||
|
||||
handle("cos:register", (src, entry) => __req___costumes.register(src, entry));
|
||||
|
||||
handle("q:spec", () => __req___quests.spec());
|
||||
|
||||
handle("q:load", (src, opts) => __req___quests.load(src, opts || {}));
|
||||
|
||||
handle("q:quest", (src, t, section) => __req___quests.quest(src, t, section));
|
||||
|
||||
handle("q:section", (src, t, file, section) => __req___quests.section(src, t, file, section));
|
||||
|
||||
handle("q:presents", (src, t) => __req___quests.presents(src, t));
|
||||
|
||||
handle("q:presentLabel", (src, t, type, v1, v2) => __req___quests.presentLabel(src, t, type, v1, v2));
|
||||
|
||||
handle("q:missions", (src, t, id) => __req___quests.missions(src, t, id));
|
||||
|
||||
handle("q:help", (src, t, index) => __req___quests.help(src, t, index));
|
||||
|
||||
handle("q:check", (src, t) => __req___quests.check(src, t));
|
||||
|
||||
handle("q:next", (src, t) => __req___quests.next(src, t));
|
||||
|
||||
handle("q:pickLists", (src, t) => __req___quests.pickLists(src, t));
|
||||
|
||||
handle("q:save", (src, t, changes) => __req___quests.save(src, t, changes));
|
||||
|
||||
handle("q:export", (src, t, folder) => __req___quests.exportTo(src, t, folder));
|
||||
|
||||
handle("dat:client", (src) => __req___dattable.clientTables(src));
|
||||
|
||||
handle("dat:folder", (folder) => __req___dattable.folderTables(folder));
|
||||
|
||||
handle("dat:open", (ref, encoding) => __req___dattable.load(ref, encoding));
|
||||
|
||||
handle("dat:encoding", (id, encoding) => __req___dattable.setEncoding(id, encoding));
|
||||
|
||||
handle("dat:save", (id, rows, opts) => __req___dattable.save(id, rows, opts || {}));
|
||||
|
||||
handle("dat:close", (id) => __req___dattable.close(id));
|
||||
|
||||
handle("dat:meta", (folder, table) => __req___dattable.meta(folder, table));
|
||||
|
||||
handle("dat:exportXlsx", (id, rows, file) => __req___dattable.exportXlsx(id, rows, file));
|
||||
|
||||
handle("dat:exportCsv", (id, rows, file) => __req___dattable.exportCsv(id, rows, file));
|
||||
|
||||
handle("dat:import", (id, file) => __req___dattable.importFile(id, file));
|
||||
|
||||
handle("mdl:texture", (src, name) => assets.texture(src, name));
|
||||
|
||||
handle("mdl:describe", (src) => assets.describe(src));
|
||||
|
||||
handle("mdl:reload", (src) => (assets.reload(src), models.forget(src)));
|
||||
|
||||
const models = __req___models;
|
||||
|
||||
handle("mdl:findSkeleton", (src, q) => models.findSkeleton(src, q));
|
||||
|
||||
handle("mdl:usedBy", (src, q) => models.usedBy(src, q));
|
||||
|
||||
handle("mdl:readFile", (file) => {
|
||||
const b = fs.readFileSync(file);
|
||||
return { bytes: new Uint8Array(b.buffer, b.byteOffset, b.byteLength), source: file, where: { kind: "file", file } };
|
||||
});
|
||||
|
||||
handle("mdl:writeBack", (where, bytes) => assets.writeBack(where, bytes));
|
||||
|
||||
handle("tex:list", (dir) =>
|
||||
fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((d) => d.isFile() && /\.(dds|png|jpe?g|tga|bmp)$/i.test(d.name))
|
||||
.map((d) => {
|
||||
const st = fs.statSync(path.join(dir, d.name));
|
||||
return { name: d.name, size: st.size, mtime: st.mtimeMs };
|
||||
}),
|
||||
);
|
||||
|
||||
handle("anim:mtime", (file) => {
|
||||
try {
|
||||
return fs.statSync(file).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
handle("mdl:writeFiles", (folder, files) => {
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
for (const f of files) {
|
||||
const target = path.join(folder, path.basename(f.name));
|
||||
fs.writeFileSync(target, typeof f.data === "string" ? Buffer.from(f.data, "latin1") : Buffer.from(f.data.buffer, f.data.byteOffset, f.data.byteLength));
|
||||
}
|
||||
return { folder, count: files.length };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Ported from LS Tools desktop: main/imagesets.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import tools from "../core/tools.js";
|
||||
import iop from "./iopman.js";
|
||||
import assets from "./assets.js";
|
||||
|
||||
// UI Image Set editor, main side: loads xml/uiimageset.xml (from an open pack or a file), finds
|
||||
// the textures its imagesets point at, and saves the XML back into the pack or to a file.
|
||||
//
|
||||
// Textures (File="ui_icon_event1.dds", a bare name) come from an asset source (see assets.js):
|
||||
// the packs next to a file open in IOP Manager, a folder of .iop packs, or a folder of extracted
|
||||
// files; resource/texture/<name> wins over the same name elsewhere.
|
||||
//
|
||||
// The XML is ASCII (euc-kr declared) and travels as a latin1 string, so every byte round-trips.
|
||||
|
||||
/** The XML inside an open IOP Manager session. */
|
||||
function loadFromPack(sessionId, entryName) {
|
||||
const { pack, password, file } = iop.handle(sessionId);
|
||||
const e = pack.lookup(entryName);
|
||||
if (!e) throw new Error(`${entryName} isn't in ${path.basename(file)}.`);
|
||||
const data = tools.readEntry(pack, e, password, { xorIni: false });
|
||||
return { text: data.toString("latin1"), source: { kind: "pack", sessionId, entry: e.name, file } };
|
||||
}
|
||||
|
||||
function loadFromFile(file) {
|
||||
return { text: fs.readFileSync(file).toString("latin1"), source: { kind: "file", file } };
|
||||
}
|
||||
|
||||
/** Writes the XML back into its pack (newest copy of the entry, comment kept). */
|
||||
function saveToPack(sessionId, entryName, text) {
|
||||
return iop.replaceData(sessionId, entryName, Buffer.from(text, "latin1"));
|
||||
}
|
||||
|
||||
function saveToFile(file, text) {
|
||||
fs.writeFileSync(file, Buffer.from(text, "latin1"));
|
||||
return { file };
|
||||
}
|
||||
|
||||
const __export_texture = assets.texture;
|
||||
const __export_describe = assets.describe;
|
||||
const __export_reloadTextures = assets.reload;
|
||||
export { loadFromPack, loadFromFile, __export_texture as texture, __export_describe as describe, saveToPack, saveToFile, __export_reloadTextures as reloadTextures };
|
||||
export default { loadFromPack, loadFromFile, texture: __export_texture, describe: __export_describe, saveToPack, saveToFile, reloadTextures: __export_reloadTextures };
|
||||
@@ -0,0 +1,198 @@
|
||||
// Ported from LS Tools desktop: main/inidoc.js (mechanical CJS -> ESM)
|
||||
// Source-preserving editing of the game's INI files and string tables: everything is kept as
|
||||
// latin1 text (bytes as they are, so the CP949 comments survive), only the lines that change are
|
||||
// rewritten, and new keys / sections are added next to their neighbours. Parsing follows the
|
||||
// client (see docs/quests.md): case-insensitive names, the first copy of a section or key wins,
|
||||
// ";" and "//" lines are comments, an empty value counts as missing.
|
||||
|
||||
class IniDoc {
|
||||
/** @param {Buffer} buf */
|
||||
constructor(buf) {
|
||||
this.lines = buf.toString("latin1").split("\n");
|
||||
// line ends: keep each line's "\r"; new lines use the file's usual ending
|
||||
const cr = this.lines.filter((l) => l.endsWith("\r")).length;
|
||||
this.cr = cr * 2 >= this.lines.length - 1 ? "\r" : "";
|
||||
this.reindex();
|
||||
}
|
||||
|
||||
reindex() {
|
||||
this.sections = new Map(); // lower name → { name, header, end, keys: Map(lower → line), last }
|
||||
this.order = [];
|
||||
let cur = null;
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
const line = this.lines[i].trim();
|
||||
if (!line || line.startsWith(";") || line.startsWith("//")) continue;
|
||||
const m = /^\[([^\]]+)\]/.exec(line);
|
||||
if (m) {
|
||||
if (cur) cur.end = i;
|
||||
const name = m[1].trim();
|
||||
const key = name.toLowerCase();
|
||||
if (this.sections.has(key)) {
|
||||
cur = { name, header: i, end: i + 1, keys: new Map(), last: i, dup: true }; // a later copy: read by nobody
|
||||
} else {
|
||||
cur = { name, header: i, end: i + 1, keys: new Map(), last: i };
|
||||
this.sections.set(key, cur);
|
||||
this.order.push(cur);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf("=");
|
||||
if (!cur || eq < 1) continue;
|
||||
const k = line.slice(0, eq).trim().toLowerCase();
|
||||
if (!cur.keys.has(k)) cur.keys.set(k, i);
|
||||
cur.last = i;
|
||||
}
|
||||
if (cur) cur.end = this.lines.length;
|
||||
}
|
||||
|
||||
section(name) {
|
||||
return this.sections.get(String(name).toLowerCase()) || null;
|
||||
}
|
||||
|
||||
has(name) {
|
||||
return this.sections.has(String(name).toLowerCase());
|
||||
}
|
||||
|
||||
/** Raw value (latin1, trimmed) or undefined. An empty value counts as missing, like the client. */
|
||||
get(sec, key) {
|
||||
const s = this.section(sec);
|
||||
const i = s?.keys.get(String(key).toLowerCase());
|
||||
if (i === undefined) return undefined;
|
||||
const line = this.lines[i];
|
||||
const v = line.slice(line.indexOf("=") + 1).trim();
|
||||
return v === "" ? undefined : v;
|
||||
}
|
||||
|
||||
/** Every key of a section: [[key as written, raw value]] in file order. */
|
||||
entries(sec) {
|
||||
const s = this.section(sec);
|
||||
if (!s) return [];
|
||||
return [...s.keys.values()]
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => {
|
||||
const line = this.lines[i];
|
||||
const eq = line.indexOf("=");
|
||||
return [line.slice(0, eq).trim(), line.slice(eq + 1).trim()];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes one section: values[key] = raw string sets or adds the key, null removes it.
|
||||
* Keys that are already there keep their place and spacing; new ones go after the last key.
|
||||
*/
|
||||
update(sec, values) {
|
||||
const s = this.section(sec);
|
||||
if (!s) throw new Error(`[${sec}] isn't in the file.`);
|
||||
const removals = [];
|
||||
const inserts = [];
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const i = s.keys.get(key.toLowerCase());
|
||||
if (value === null || value === undefined || value === "") {
|
||||
if (i !== undefined) removals.push(i);
|
||||
continue;
|
||||
}
|
||||
const v = String(value);
|
||||
if (/[\r\n]/.test(v)) throw new Error(`${key}: a value can't span lines.`);
|
||||
if (i !== undefined) {
|
||||
const line = this.lines[i];
|
||||
const cr = line.endsWith("\r") ? "\r" : "";
|
||||
const body = cr ? line.slice(0, -1) : line;
|
||||
const eq = body.indexOf("=");
|
||||
const lead = /^[ \t]*/.exec(body.slice(eq + 1))[0];
|
||||
this.lines[i] = `${body.slice(0, eq + 1)}${lead || " "}${v}${cr}`;
|
||||
} else inserts.push(`${key}\t=\t${v}${this.cr}`);
|
||||
}
|
||||
let at = s.last + 1;
|
||||
for (const i of removals.sort((a, b) => b - a)) {
|
||||
this.lines.splice(i, 1);
|
||||
if (i < at) at--;
|
||||
}
|
||||
if (inserts.length) this.lines.splice(at, 0, ...inserts);
|
||||
if (removals.length || inserts.length) this.reindex();
|
||||
}
|
||||
|
||||
/** Adds [name] with its keys at the end of the file (or after the section `after`). */
|
||||
addSection(name, entries = [], { after = null } = {}) {
|
||||
if (this.has(name)) throw new Error(`[${name}] is already in the file.`);
|
||||
const block = [`[${name}]${this.cr}`, ...entries.filter(([, v]) => v !== null && v !== undefined && v !== "").map(([k, v]) => `${k}\t=\t${v}${this.cr}`)];
|
||||
const prev = after ? this.section(after) : null;
|
||||
if (prev) {
|
||||
this.lines.splice(prev.last + 1, 0, ...block);
|
||||
} else {
|
||||
// before the trailing empty line(s) of the file
|
||||
let end = this.lines.length;
|
||||
while (end > 0 && this.lines[end - 1].trim() === "") end--;
|
||||
this.lines.splice(end, 0, ...block);
|
||||
}
|
||||
this.reindex();
|
||||
}
|
||||
|
||||
/** Removes a section: its header and every line up to its last key (comments inside it too). */
|
||||
removeSection(name) {
|
||||
const s = this.section(name);
|
||||
if (!s) return;
|
||||
this.lines.splice(s.header, s.last - s.header + 1);
|
||||
this.reindex();
|
||||
}
|
||||
|
||||
toBuffer() {
|
||||
return Buffer.from(this.lines.join("\n"), "latin1");
|
||||
}
|
||||
}
|
||||
|
||||
/** The string table ("|key|text|" lines; text in the region's code page). Keys are lowercase-matched. */
|
||||
class TextDoc {
|
||||
constructor(buf) {
|
||||
this.lines = buf.toString("latin1").split("\n");
|
||||
const cr = this.lines.filter((l) => l.endsWith("\r")).length;
|
||||
this.cr = cr * 2 >= this.lines.length - 1 ? "\r" : "";
|
||||
this.index = new Map();
|
||||
this.lines.forEach((line, i) => {
|
||||
if (line[0] !== "|") return;
|
||||
const k = line.indexOf("|", 1);
|
||||
if (k < 0) return;
|
||||
const key = line.slice(1, k).toLowerCase();
|
||||
if (!this.index.has(key)) this.index.set(key, i);
|
||||
});
|
||||
}
|
||||
|
||||
/** How the table writes the "ini_" prefix of INI keys ("INI_" in the HK table), for new keys. */
|
||||
iniPrefix() {
|
||||
if (this._prefix === undefined) {
|
||||
const line = this.lines.find((l) => /^\|ini_/i.test(l));
|
||||
this._prefix = line ? line.slice(1, 5) : "ini_";
|
||||
}
|
||||
return this._prefix;
|
||||
}
|
||||
|
||||
/** Raw text bytes (as a latin1 string) of a key, or undefined. */
|
||||
get(key) {
|
||||
const i = this.index.get(key.toLowerCase());
|
||||
if (i === undefined) return undefined;
|
||||
const line = this.lines[i].replace(/\r$/, "");
|
||||
const k = line.indexOf("|", 1);
|
||||
return line.slice(k + 1, line.lastIndexOf("|"));
|
||||
}
|
||||
|
||||
/** Sets a key's text (latin1 string of the encoded bytes); new keys go at the end of the table. */
|
||||
set(key, raw) {
|
||||
const i = this.index.get(key.toLowerCase());
|
||||
if (i !== undefined) {
|
||||
const cr = this.lines[i].endsWith("\r") ? "\r" : "";
|
||||
const k = this.lines[i].indexOf("|", 1);
|
||||
this.lines[i] = `${this.lines[i].slice(0, k + 1)}${raw}|${cr}`;
|
||||
return;
|
||||
}
|
||||
let end = this.lines.length;
|
||||
while (end > 0 && this.lines[end - 1].replace(/\r$/, "") === "") end--;
|
||||
this.lines.splice(end, 0, `|${key}|${raw}|${this.cr}`);
|
||||
this.index.set(key.toLowerCase(), end);
|
||||
}
|
||||
|
||||
toBuffer() {
|
||||
return Buffer.from(this.lines.join("\n"), "latin1");
|
||||
}
|
||||
}
|
||||
|
||||
export { IniDoc, TextDoc };
|
||||
export default { IniDoc, TextDoc };
|
||||
@@ -0,0 +1,47 @@
|
||||
// Web stand-in for the desktop iopman.js (IOP Manager sessions). The packs of the opened game folder
|
||||
// are registered as sessions, so every tool that works with "a pack open in IOP Manager" (UI image
|
||||
// sets: Load, texture source "packs next to …") works with the game folder. The web IOP Manager
|
||||
// (PLAN phase 8b) will add its own packs here too.
|
||||
// Session ids are numbers, like the desktop's: the renderer does Number(select.value).
|
||||
const registry = new Map(); // id -> { id, name, file, pack, key, game }
|
||||
const idOfFile = new Map(); // lower file path -> id (stable while the page is open)
|
||||
let nextId = 1;
|
||||
|
||||
/** Replace the game folder's packs: [{ name, file, pack, key }] (key = detected profile or null). */
|
||||
export function setGamePacks(list) {
|
||||
for (const [id, s] of [...registry]) if (s.game) registry.delete(id);
|
||||
for (const p of list) {
|
||||
const k = p.file.toLowerCase();
|
||||
if (!idOfFile.has(k)) idOfFile.set(k, nextId++);
|
||||
const id = idOfFile.get(k);
|
||||
registry.set(id, { id, game: true, ...p });
|
||||
}
|
||||
}
|
||||
|
||||
/** Same shape as the desktop: { id, name, file, encrypted, detected, profileId }. */
|
||||
export function sessions() {
|
||||
return [...registry.values()].map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
file: s.file,
|
||||
encrypted: !!s.encrypted,
|
||||
detected: !!s.key,
|
||||
profileId: s.key ? s.key.id : null,
|
||||
entries: s.pack.liveEntries().length,
|
||||
}));
|
||||
}
|
||||
|
||||
/** { pack, password (type 0 of the detected key), file } of a session. */
|
||||
export function handle(id) {
|
||||
const s = registry.get(Number(id));
|
||||
if (!s) throw new Error("That pack is not open any more. Open the game folder again.");
|
||||
return { pack: s.pack, password: s.key ? s.key.passwords[0] : null, file: s.file };
|
||||
}
|
||||
|
||||
export async function addData() {
|
||||
throw new Error("Saving into packs is not available yet.");
|
||||
}
|
||||
|
||||
export const replaceData = addData;
|
||||
|
||||
export default { sessions, handle, addData, replaceData, setGamePacks };
|
||||
@@ -0,0 +1,595 @@
|
||||
// Ported from LS Tools desktop: main/items.js (mechanical CJS -> ESM)
|
||||
import assets from "./assets.js";
|
||||
import codec from "./lsmon/codec.js"; // web port: Korean strings left in Latin text tables
|
||||
|
||||
// Item Viewer: every item the client knows, read the way the client reads it (see README → Items),
|
||||
// straight from the game's packs (an asset source, normally the profile's game folder), so it always
|
||||
// shows what the current rs.iop / map.iop hold.
|
||||
//
|
||||
// config/*.ini (rs.iop) item definitions; names / descriptions are "STR(n)"
|
||||
// resource/text/text_*.txt the string table (map.iop): "|key|text|" per line, key =
|
||||
// lower("ini_" + ini file stem + "::" + section + "_" + n)
|
||||
// *_manual.ini descriptions: [ManualN] <line>Text<k> / <line>Color<k> (read on demand)
|
||||
// xml/uiimageset.xml icons ("Set#Image"); drawn by the window (UIRes)
|
||||
//
|
||||
// Categories (client managers): etc items (ioEtcItemManager), equipment (ioItemMaker: sp2_item /
|
||||
// sp2_extraitem / … group files), equipment boxes (ioExtraItemInfoManager), costumes and costume
|
||||
// boxes (ioCostumeInfoManager / ioCostumeShopInfoManager), medals (ioMedalItemInfoManager),
|
||||
// decorations (ioDecorationPrice), heroes (ioSetItemInfoManager). Hero prices come from the server.
|
||||
|
||||
// Text tables per client region, and their code page. The first one the packs hold is the default.
|
||||
// Web port fix: the region tables come before the Korean base table — a regional client (e.g.
|
||||
// Gemscool Indonesia: text.txt + text_us + text_id) keeps text.txt only as a leftover full of
|
||||
// STR(n) placeholders, so Korean first showed every quest and item as "STR(1)".
|
||||
const TEXTS = [
|
||||
{ file: "text_tw.txt", label: "Traditional Chinese (TW / HK)", encoding: "big5" },
|
||||
{ file: "text_th.txt", label: "Thai", encoding: "windows-874" },
|
||||
{ file: "text_id.txt", label: "Indonesian", encoding: "windows-1252" },
|
||||
{ file: "text_us.txt", label: "English (US)", encoding: "windows-1252" },
|
||||
{ file: "text_kr.txt", label: "Korean", encoding: "euc-kr" },
|
||||
{ file: "text.txt", label: "Korean (base)", encoding: "euc-kr" },
|
||||
];
|
||||
|
||||
const cache = new Map(); // source key + text → db
|
||||
|
||||
// ---------- reading ----------
|
||||
|
||||
const srcKey = (src) => JSON.stringify(src);
|
||||
|
||||
/** A config file the way the client finds it: rs.iop first, then anywhere. → Buffer | null */
|
||||
function readConfig(src, name) {
|
||||
const hit = assets.readKey(src, `rs.iop|config/${name}`) || assets.readPath(src, `config/${name}`);
|
||||
return hit ? Buffer.from(hit.bytes) : null;
|
||||
}
|
||||
|
||||
/** Which string tables the packs hold. */
|
||||
function textFiles(src) {
|
||||
const have = new Map();
|
||||
for (const it of assets.list(src)) {
|
||||
const m = /(^|\/)text\/(text[^/]*\.txt)$/i.exec(it.path);
|
||||
if (m && !have.has(m[2].toLowerCase())) have.set(m[2].toLowerCase(), it.key);
|
||||
}
|
||||
return TEXTS.filter((t) => have.has(t.file)).map((t) => ({ ...t, key: have.get(t.file) }));
|
||||
}
|
||||
|
||||
/** "|key|text|" lines → Map(key lower → raw bytes). The first copy of a key wins, like the client. */
|
||||
function parseTextTable(buf) {
|
||||
const table = new Map();
|
||||
let start = 0;
|
||||
for (let i = 0; i <= buf.length; i++) {
|
||||
if (i < buf.length && buf[i] !== 0x0a) continue;
|
||||
let end = i;
|
||||
if (end > start && buf[end - 1] === 0x0d) end--;
|
||||
// key: ASCII up to the second "|"; text: up to the last "|" of the line (a Big5 trail byte can be 0x7C, never the last one)
|
||||
if (buf[start] === 0x7c) {
|
||||
const k = buf.indexOf(0x7c, start + 1);
|
||||
const last = buf.lastIndexOf(0x7c, end - 1);
|
||||
if (k > start && k < end && last > k) {
|
||||
const key = buf.toString("latin1", start + 1, k).toLowerCase();
|
||||
if (!table.has(key)) table.set(key, buf.subarray(k + 1, last));
|
||||
}
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* INI → Map(section lower → { name, keys: Map(key lower → value) }). Values stay latin1 strings
|
||||
* (bytes as they are); comment lines (";", "//") are skipped. Sections and keys case-insensitive,
|
||||
* the first copy wins, like the client's parser.
|
||||
*/
|
||||
function parseIni(buf) {
|
||||
const out = new Map();
|
||||
let cur = null;
|
||||
for (const raw of buf.toString("latin1").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith(";") || line.startsWith("//")) continue;
|
||||
const sec = /^\[([^\]]+)\]/.exec(line);
|
||||
if (sec) {
|
||||
const name = sec[1].trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!out.has(key)) out.set(key, { name, keys: new Map() });
|
||||
cur = out.get(key);
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf("=");
|
||||
if (!cur || eq < 1) continue;
|
||||
const k = line.slice(0, eq).trim().toLowerCase();
|
||||
if (!cur.keys.has(k)) cur.keys.set(k, line.slice(eq + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- one loaded game ----------
|
||||
|
||||
class ItemDb {
|
||||
constructor(src, text) {
|
||||
this.src = src;
|
||||
this.text = text; // { file, encoding, … } or null
|
||||
this.decoder = codec.textDecoder(text?.encoding || "euc-kr");
|
||||
// the INI files themselves are written in Korean (CP949): a value that isn't STR(n) is Korean text
|
||||
this.rawDecoder = new TextDecoder("euc-kr");
|
||||
this.table = new Map();
|
||||
this.inis = new Map(); // file name → parsed ini (null when missing)
|
||||
this.missing = new Set();
|
||||
this.categories = [];
|
||||
}
|
||||
|
||||
ini(name) {
|
||||
const key = name.toLowerCase();
|
||||
if (!this.inis.has(key)) {
|
||||
const buf = readConfig(this.src, key);
|
||||
if (!buf) this.missing.add(key);
|
||||
this.inis.set(key, buf ? parseIni(buf) : null);
|
||||
}
|
||||
return this.inis.get(key);
|
||||
}
|
||||
|
||||
/** A value of `file`'s [section]: STR(n) from the string table, else the raw text in the region's code page. */
|
||||
str(file, section, value) {
|
||||
if (value == null || value === "") return "";
|
||||
const m = /STR\(\s*(\d+)/i.exec(value);
|
||||
if (m && Number(m[1]) > 0) {
|
||||
// the client keys by the file name alone (no folder, no extension): costume/costume_armor.ini → costume_armor
|
||||
const stem = file.split(/[\\/]/).pop().replace(/\.ini$/i, "").toLowerCase();
|
||||
const bytes = this.table.get(`ini_${stem}::${section}_${m[1]}`.toLowerCase());
|
||||
return bytes ? this.decoder.decode(bytes).replace(/\\r/g, "").replace(/\\n/g, "\n") : value;
|
||||
}
|
||||
return /[\x80-\xff]/.test(value) ? this.rawDecoder.decode(Buffer.from(value, "latin1")) : value;
|
||||
}
|
||||
|
||||
load() {
|
||||
if (this.text) {
|
||||
const hit = assets.readKey(this.src, this.text.key);
|
||||
if (hit) this.table = parseTextTable(Buffer.from(hit.bytes));
|
||||
}
|
||||
const cats = [
|
||||
["etc", "Etc items", () => this.etcItems()],
|
||||
["equip", "Equipment", () => this.equipment()],
|
||||
["equipbox", "Equipment boxes", () => this.machines("sp2_extraitem_info.ini", "random_machine", "machine_code", "sp2_etc_manual.ini")],
|
||||
["costume", "Costumes", () => this.costumes()],
|
||||
["costumebox", "Costume boxes", () => this.machines("sp2_costume_shop_info.ini", "costume_machine", "costume_shop_code", "sp2_costume_shop_manual.ini")],
|
||||
["medal", "Medals", () => this.medals()],
|
||||
["deco", "Decorations", () => this.decorations()],
|
||||
["hero", "Heroes", () => this.heroes()],
|
||||
];
|
||||
for (const [id, label, fn] of cats) {
|
||||
let items = [];
|
||||
let error = null;
|
||||
try {
|
||||
items = fn();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
this.categories.push({ id, label, items, error });
|
||||
}
|
||||
// names for box contents (equipment / costume codes)
|
||||
this.names = new Map();
|
||||
for (const c of this.categories) for (const it of c.items) if (!this.names.has(`${c.id}:${it.id}`)) this.names.set(`${c.id}:${it.id}`, { name: it.name, icon: it.icon });
|
||||
for (const c of this.categories.filter((x) => x.id === "equipbox" || x.id === "costumebox")) {
|
||||
const pool = c.id === "equipbox" ? "equip" : "costume";
|
||||
for (const it of c.items) for (const x of it.contents || []) Object.assign(x, this.names.get(`${pool}:${x.code}`) || this.extraList().get(x.code) || {});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Section values as a plain object (raw), for the "all fields" list. */
|
||||
raw(sec) {
|
||||
return Object.fromEntries(sec.keys);
|
||||
}
|
||||
|
||||
// ----- etc items: sp2_etcitem_info.ini [etcitem1..max] -----
|
||||
etcItems() {
|
||||
const F = "sp2_etcitem_info.ini";
|
||||
const ini = this.ini(F);
|
||||
if (!ini) return [];
|
||||
const max = parseInt(ini.get("common")?.keys.get("max") || "0", 10) || 20000;
|
||||
const out = [];
|
||||
for (let i = 1; i <= max; i++) {
|
||||
const sec = ini.get(`etcitem${i}`);
|
||||
if (!sec) continue;
|
||||
const k = (n) => sec.keys.get(n);
|
||||
const shop = [];
|
||||
for (let s = 1; s <= 100 && k(`value${s}`) !== undefined; s++) {
|
||||
shop.push({
|
||||
label: this.str(F, sec.name, k(`shop_name${s}`)) || "",
|
||||
value: num(k(`value${s}`)),
|
||||
peso: num(k(`peso${s}`)),
|
||||
cash: num(k(`cash${s}`)),
|
||||
bonusPeso: num(k(`bonus_peso${s}`)),
|
||||
active: k(`active${s}`) === undefined ? null : num(k(`active${s}`)) !== 0,
|
||||
});
|
||||
}
|
||||
const type = num(k("type"));
|
||||
out.push({
|
||||
id: type,
|
||||
section: sec.name,
|
||||
name: this.str(F, sec.name, k("name")),
|
||||
icon: k("icon_name") || "",
|
||||
grade: num(k("grade_type")),
|
||||
kind: USE_TYPES[Math.floor(type / 1000000)] || "",
|
||||
sellPeso: num(k("sell_peso")),
|
||||
canSell: k("can_sell") === undefined ? null : num(k("can_sell")) !== 0,
|
||||
shop,
|
||||
manuals: [
|
||||
{ file: "sp2_etc_inven_manual.ini", n: num(k("inventory_manual")), label: "Inventory" },
|
||||
{ file: "sp2_etc_manual.ini", n: num(k("main_manual1")), label: "Shop" },
|
||||
].filter((m) => m.n > 0),
|
||||
file: F,
|
||||
fields: this.raw(sec),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* sp2_extraitem_list.ini: what the shop shows per equipment box ([machine_<N>] item<i>_code /
|
||||
* item<i>_name / item<i>_icon). Its names are the translated ones (the hero equipment files keep
|
||||
* the Korean originals), so equipment and box contents are named from here by code.
|
||||
*/
|
||||
extraList() {
|
||||
if (this._extraList) return this._extraList;
|
||||
const F = "sp2_extraitem_list.ini";
|
||||
const map = new Map();
|
||||
const ini = this.ini(F);
|
||||
for (const sec of ini ? ini.values() : []) {
|
||||
const cnt = num(sec.keys.get("item_cnt"));
|
||||
for (let i = 1; i <= cnt; i++) {
|
||||
const code = num(sec.keys.get(`item${i}_code`));
|
||||
if (!code) continue;
|
||||
const name = this.str(F, sec.name, sec.keys.get(`item${i}_name`));
|
||||
const icon = sec.keys.get(`item${i}_icon`) || "";
|
||||
const have = map.get(code);
|
||||
if (!have || (!have.name && name) || (!have.icon && icon)) map.set(code, { name: have?.name || name, icon: have?.icon || icon });
|
||||
}
|
||||
}
|
||||
return (this._extraList = map);
|
||||
}
|
||||
|
||||
// ----- equipment: sp2_item.ini / sp2_extraitem.ini / … → group files [item1..] -----
|
||||
equipment() {
|
||||
const list = this.extraList();
|
||||
const out = [];
|
||||
for (const top of ["sp2_item.ini", "sp2_extraitem.ini", "sp2_powerup_item.ini", "sp2_powerup_extraitem.ini", "sp2_accessory.ini", "sp2_item_npc.ini"]) {
|
||||
const ini = this.ini(top);
|
||||
if (!ini) continue;
|
||||
const info = ini.get("common_info");
|
||||
const cnt = num(info?.keys.get("item_group_cnt"));
|
||||
for (let g = 0; g < cnt; g++) {
|
||||
const file = info.keys.get(`item_group${g}`);
|
||||
if (!file) continue;
|
||||
const name = /\.ini$/i.test(file) ? file : `${file}.ini`;
|
||||
const gi = this.ini(name.replace(/^config[\\/]/i, ""));
|
||||
if (!gi) continue;
|
||||
const n = num(gi.get("common")?.keys.get("item_cnt")) || 5000;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const sec = gi.get(`item${i}`);
|
||||
if (!sec) continue;
|
||||
const k = (x) => sec.keys.get(x);
|
||||
const gf = name.replace(/^config[\\/]/i, "");
|
||||
const code = num(k("code"));
|
||||
const own = this.str(gf, sec.name, k("name"));
|
||||
const shown = list.get(code);
|
||||
out.push({
|
||||
id: code,
|
||||
section: sec.name,
|
||||
// the translated name from sp2_extraitem_list when there is one, else the file's own
|
||||
name: (shown?.name && !/^STR\(/i.test(shown.name) ? shown.name : "") || own,
|
||||
originalName: shown?.name && shown.name !== own ? own : undefined,
|
||||
icon: k("item_large_icon0") || k("item_large_icon") || shown?.icon || "",
|
||||
grade: num(k("grade_type")),
|
||||
kind: (k("item_type") || "").toUpperCase(),
|
||||
group: `${top.replace(/\.ini$/i, "")} / ${gf.replace(/\.ini$/i, "")}`,
|
||||
enableClass: k("enable_class") || "",
|
||||
manuals: num(k("manual")) > 0 ? [{ file: "sp2_extra_inven_manual.ini", n: num(k("manual")), label: "Inventory" }] : [],
|
||||
file: gf,
|
||||
fields: this.raw(sec),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ----- equipment / costume boxes: [default<type>] templates, then [<prefix>N] -----
|
||||
machines(F, prefix, codeKey, manualFile) {
|
||||
const ini = this.ini(F);
|
||||
if (!ini) return [];
|
||||
const out = [];
|
||||
for (const [key, sec] of ini) {
|
||||
const m = new RegExp(`^${prefix}(\\d+)$`, "i").exec(key);
|
||||
if (!m) continue;
|
||||
// the client copies [default<type>] first, then this section overrides it
|
||||
const merged = new Map();
|
||||
const tpl = ini.get(`default${sec.keys.get("type") || ""}`);
|
||||
if (tpl) for (const [a, b] of tpl.keys) merged.set(a, { v: b, from: tpl.name });
|
||||
for (const [a, b] of sec.keys) merged.set(a, { v: b, from: sec.name });
|
||||
const k = (x) => merged.get(x)?.v;
|
||||
const s = (x) => (merged.has(x) ? this.str(F, merged.get(x).from, merged.get(x).v) : "");
|
||||
const shop = [];
|
||||
if (k("need_cash") !== undefined && num(k("need_cash")) > 0) shop.push({ label: "", value: null, peso: 0, cash: num(k("need_cash")), bonusPeso: num(k("bonus_peso")), active: null });
|
||||
for (let i = 1; i <= 50 && k(`need_peso${i}`) !== undefined; i++) {
|
||||
shop.push({ label: "", value: null, period: num(k(`period${i}`)), peso: num(k(`need_peso${i}`)), cash: 0, bonusPeso: 0, active: null });
|
||||
}
|
||||
const contents = [];
|
||||
for (let i = 1; i <= num(k("item_cnt")); i++) {
|
||||
if (k(`item${i}_code`) === undefined) continue;
|
||||
contents.push({ code: num(k(`item${i}_code`)), rate: num(k(`item${i}_rate`)) });
|
||||
}
|
||||
const total = contents.reduce((n, c) => n + c.rate, 0);
|
||||
for (const c of contents) c.percent = total ? (c.rate * 100) / total : null;
|
||||
out.push({
|
||||
id: num(k(codeKey)) || num(m[1]),
|
||||
section: sec.name,
|
||||
name: s("name"),
|
||||
icon: k("icon") || "",
|
||||
grade: num(k("grade_type")),
|
||||
kind: `type ${k("type") || "-"}`,
|
||||
needLevel: num(k("need_level")),
|
||||
sellPeso: num(k("sell_peso")),
|
||||
active: k("active") === undefined ? null : num(k("active")) !== 0,
|
||||
shop,
|
||||
contents,
|
||||
manuals: num(k("manual")) > 0 ? [{ file: manualFile, n: num(k("manual")), label: "Shop" }] : [],
|
||||
file: F,
|
||||
fields: Object.fromEntries([...merged].map(([a, b]) => [a, b.v])),
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
// ----- costumes: sp2_costume.ini → group files [costume1..] -----
|
||||
costumes() {
|
||||
const ini = this.ini("sp2_costume.ini");
|
||||
if (!ini) return [];
|
||||
const info = ini.get("common_info");
|
||||
const cnt = num(info?.keys.get("costume_group_cnt"));
|
||||
const out = [];
|
||||
for (let g = 1; g <= cnt; g++) {
|
||||
const file = info.keys.get(`costume_group${g}`);
|
||||
if (!file) continue;
|
||||
const gf = (/\.ini$/i.test(file) ? file : `${file}.ini`).replace(/^config[\\/]/i, "");
|
||||
const gi = this.ini(gf);
|
||||
if (!gi) continue;
|
||||
const n = num(gi.get("common_info")?.keys.get("costume_cnt")) || 5000;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const sec = gi.get(`costume${i}`);
|
||||
if (!sec) continue;
|
||||
const k = (x) => sec.keys.get(x);
|
||||
out.push({
|
||||
id: num(k("costum_code")),
|
||||
section: sec.name,
|
||||
name: this.str(gf, sec.name, k("name")),
|
||||
icon: k("icon_name") || "",
|
||||
grade: num(k("grade_type")),
|
||||
kind: "",
|
||||
group: gf.replace(/\.ini$/i, ""),
|
||||
enableClass: k("enable_class") || "",
|
||||
manuals: num(k("manual")) > 0 ? [{ file: "sp2_costume_inven_manual.ini", n: num(k("manual")), label: "Inventory" }] : [],
|
||||
file: gf,
|
||||
fields: this.raw(sec),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ----- medals: sp2_medalitem_info.ini [item_info_<N>] -----
|
||||
medals() {
|
||||
const F = "sp2_medalitem_info.ini";
|
||||
const ini = this.ini(F);
|
||||
if (!ini) return [];
|
||||
const max = num(ini.get("item_info_common")?.keys.get("max_item_info")) || 2000;
|
||||
const out = [];
|
||||
for (let i = 1; i <= max; i++) {
|
||||
const sec = ini.get(`item_info_${i}`);
|
||||
if (!sec) continue;
|
||||
const k = (x) => sec.keys.get(x);
|
||||
out.push({
|
||||
id: num(k("item_type")),
|
||||
section: sec.name,
|
||||
name: this.str(F, sec.name, k("name")),
|
||||
icon: k("icon") || "",
|
||||
grade: 0,
|
||||
kind: k("sub_medal_type") !== undefined ? `sub type ${k("sub_medal_type")}` : "",
|
||||
needLevel: num(k("limit_level")),
|
||||
sellPeso: num(k("sell_peso")),
|
||||
manuals: num(k("manual")) > 0 ? [{ file: "sp2_medal_inven_manual.ini", n: num(k("manual")), label: "Inventory" }] : [],
|
||||
file: F,
|
||||
fields: this.raw(sec),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ----- decorations: sp2_deco_sex.ini [Sex<N>] → Deco_ini [Deco<j>] entries _k -----
|
||||
decorations() {
|
||||
const ini = this.ini("sp2_deco_sex.ini");
|
||||
if (!ini) return [];
|
||||
const maxSex = num(ini.get("info")?.keys.get("max_sex")) || 2;
|
||||
const out = [];
|
||||
for (let s = 1; s <= maxSex; s++) {
|
||||
const sx = ini.get(`sex${s}`);
|
||||
const file = sx?.keys.get("deco_ini");
|
||||
if (!file) continue;
|
||||
const df = (/\.ini$/i.test(file) ? file : `${file}.ini`).replace(/^config[\\/]/i, "");
|
||||
const di = this.ini(df);
|
||||
if (!di) continue;
|
||||
const sexName = this.str("sp2_deco_sex.ini", sx.name, sx.keys.get("name")) || `sex ${sx.keys.get("sex")}`;
|
||||
const maxDeco = num(di.get("info")?.keys.get("max_deco")) || 200;
|
||||
for (let j = 1; j <= maxDeco; j++) {
|
||||
const sec = di.get(`deco${j}`);
|
||||
if (!sec) continue;
|
||||
const kk = (x) => sec.keys.get(x);
|
||||
const title = this.str(df, sec.name, kk("title"));
|
||||
for (let e = 1; e <= (num(kk("max")) || 0); e++) {
|
||||
if (kk(`code_${e}`) === undefined) continue;
|
||||
const shop = [];
|
||||
if (num(kk(`peso_${e}`)) || num(kk(`cash_${e}`))) shop.push({ label: "", value: null, peso: num(kk(`peso_${e}`)), cash: num(kk(`cash_${e}`)), bonusPeso: num(kk(`bonuspeso_${e}`)), active: kk(`active_${e}`) === undefined ? null : num(kk(`active_${e}`)) !== 0 });
|
||||
const fields = {};
|
||||
for (const [a, b] of sec.keys) if (a.endsWith(`_${e}`) || !/_\d+$/.test(a)) fields[a] = b;
|
||||
out.push({
|
||||
id: num(kk(`code_${e}`)),
|
||||
section: `${sec.name} #${e}`,
|
||||
name: this.str(df, sec.name, kk(`name_${e}`)),
|
||||
icon: kk(`icon_${e}`) || "",
|
||||
grade: 0,
|
||||
kind: `${sexName} · ${title} (type ${kk("type")})`,
|
||||
needLevel: num(kk(`limitlevel_${e}`)),
|
||||
sellPeso: num(kk(`sellpeso_${e}`)),
|
||||
shop,
|
||||
manuals: [
|
||||
{ file: "sp2_deco_inven_manual.ini", n: num(kk(`invenmanual_${e}`)), label: "Inventory" },
|
||||
{ file: "sp2_deco_manual.ini", n: num(kk(`manual_${e}`)), label: "Shop" },
|
||||
].filter((m) => m.n > 0),
|
||||
file: df,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ----- heroes: sp2_setitem_info.ini [set_item<N>] (prices come from the server) -----
|
||||
heroes() {
|
||||
const F = "sp2_setitem_info.ini";
|
||||
const ini = this.ini(F);
|
||||
if (!ini) return [];
|
||||
const max = num(ini.get("common")?.keys.get("max_set_item")) || 1000;
|
||||
const out = [];
|
||||
for (let i = 1; i <= max; i++) {
|
||||
const sec = ini.get(`set_item${i}`);
|
||||
if (!sec) continue;
|
||||
const k = (x) => sec.keys.get(x);
|
||||
const code = num(k("set_code"));
|
||||
const cls = code - 700000;
|
||||
out.push({
|
||||
id: code,
|
||||
section: sec.name,
|
||||
name: this.str(F, sec.name, k("name")),
|
||||
// the client builds the icon: SoldierIconPack<pack>#<class>, 18 classes per pack
|
||||
icon: cls > 0 ? `SoldierIconPack${String(Math.floor((cls - 1) / 18) + 1).padStart(3, "0")}#${String(cls).padStart(3, "0")}` : "",
|
||||
grade: 0,
|
||||
kind: `class ${cls}`,
|
||||
needLevel: num(k("need_level1")),
|
||||
manuals: num(k("item_manual")) > 0 ? [{ file: "sp2_item_manual.ini", n: num(k("item_manual")), label: "Shop" }] : [],
|
||||
file: F,
|
||||
fields: this.raw(sec),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A description: [Manual<n>] of a manual ini → lines of coloured runs (+ title). */
|
||||
manual(file, n) {
|
||||
const ini = this.ini(file);
|
||||
const sec = ini?.get(`manual${n}`);
|
||||
if (!sec) return null;
|
||||
const k = (x) => sec.keys.get(x);
|
||||
const runs = (prefix) => {
|
||||
const out = [];
|
||||
for (let j = 1; j <= 20; j++) {
|
||||
const t = k(`${prefix}text${j}`);
|
||||
if (t === undefined || t === "") break;
|
||||
out.push({ text: this.str(file, sec.name, t).replace(/#/g, " "), color: colorOf(k(`${prefix}color${j}`)) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const title = runs("title");
|
||||
const lines = [];
|
||||
for (let l = 1; l <= 200; l++) {
|
||||
const r = runs(String(l));
|
||||
if (!r.length) break;
|
||||
lines.push({ runs: r, space: num(k(`${l}spaceline`)) });
|
||||
}
|
||||
return { file, n, title, lines };
|
||||
}
|
||||
}
|
||||
|
||||
const USE_TYPES = { 1: "count", 2: "time", 3: "once", 4: "eternal", 5: "date" };
|
||||
const num = (v) => {
|
||||
const n = parseInt(String(v ?? "").trim(), 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const colorOf = (v) => {
|
||||
const m = /^0x([0-9a-f]{6,8})$/i.exec(String(v || "").trim());
|
||||
return m ? `#${m[1].slice(-6)}` : null;
|
||||
};
|
||||
|
||||
// ---------- API for the window ----------
|
||||
|
||||
/** What the source offers: its string tables (to pick the language). */
|
||||
function sources(src) {
|
||||
return { texts: textFiles(src).map(({ file, label, encoding }) => ({ file, label, encoding })), describe: assets.describe(src) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads (or returns the loaded) item lists. reload: read the packs again (the viewer always shows
|
||||
* the packs as they are now). → { texts, text, categories: [{ id, label, count, error, items }], missing }
|
||||
*/
|
||||
function load(src, { textFile = null, reload = false } = {}) {
|
||||
const texts = textFiles(src);
|
||||
const text = texts.find((t) => t.file === textFile) || texts[0] || null;
|
||||
const key = `${srcKey(src)}|${text?.file || ""}`;
|
||||
if (reload) {
|
||||
assets.reload(src);
|
||||
for (const k of [...cache.keys()]) if (k.startsWith(srcKey(src))) cache.delete(k);
|
||||
}
|
||||
if (!cache.has(key)) cache.set(key, new ItemDb(src, text).load());
|
||||
const db = cache.get(key);
|
||||
// the lists without each item's raw fields (those come with detail()): a few MB instead of ~30
|
||||
const light = ({ fields, ...rest }) => rest;
|
||||
return {
|
||||
texts: texts.map(({ file, label }) => ({ file, label })),
|
||||
text: text?.file || null,
|
||||
strings: db.table.size,
|
||||
missing: [...db.missing],
|
||||
categories: db.categories.map((c) => ({ id: c.id, label: c.label, error: c.error, items: c.items.map(light) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The loaded db of a source + text table (loads it when needed). */
|
||||
function dbOf(src, textFile) {
|
||||
const texts = textFiles(src);
|
||||
const text = texts.find((t) => t.file === textFile) || texts[0] || null;
|
||||
const key = `${srcKey(src)}|${text?.file || ""}`;
|
||||
if (!cache.has(key)) load(src, { textFile });
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
/** One item in full: its raw INI fields and its descriptions. */
|
||||
function detail(src, textFile, categoryId, index) {
|
||||
const db = dbOf(src, textFile);
|
||||
const it = db.categories.find((c) => c.id === categoryId)?.items[index];
|
||||
if (!it) return null;
|
||||
return { fields: it.fields, file: it.file, section: it.section, manuals: it.manuals.map((m) => ({ ...m, manual: db.manual(m.file, m.n) })) };
|
||||
}
|
||||
|
||||
function manual(src, textFile, file, n) {
|
||||
return dbOf(src, textFile).manual(file, n);
|
||||
}
|
||||
|
||||
/** Name + icon lookups per category (id → { name, icon }), e.g. for quest rewards. */
|
||||
function lookup(src, textFile) {
|
||||
const db = dbOf(src, textFile);
|
||||
if (!db.lookup) {
|
||||
db.lookup = {};
|
||||
for (const c of db.categories) {
|
||||
const m = new Map();
|
||||
for (const it of c.items) if (!m.has(it.id)) m.set(it.id, { name: it.name, icon: it.icon });
|
||||
db.lookup[c.id] = m;
|
||||
}
|
||||
}
|
||||
return db.lookup;
|
||||
}
|
||||
|
||||
/** The loaded text table's decoder and the lists (for pickers): [{ id, label, items: [{ id, name, icon }] }]. */
|
||||
function pickLists(src, textFile) {
|
||||
return dbOf(src, textFile).categories.map((c) => ({ id: c.id, label: c.label, items: c.items.map((it) => ({ id: it.id, name: it.name, icon: it.icon })) }));
|
||||
}
|
||||
|
||||
const __export_cacheReset = (src) => [...cache.keys()].filter((k) => k.startsWith(srcKey(src))).forEach((k) => cache.delete(k));
|
||||
export { load, detail, manual, sources, parseIni, parseTextTable, lookup, pickLists, textFiles, __export_cacheReset as cacheReset };
|
||||
export default { load, detail, manual, sources, parseIni, parseTextTable, lookup, pickLists, textFiles, cacheReset: __export_cacheReset };
|
||||
@@ -0,0 +1,82 @@
|
||||
// Ported from LS Tools desktop: main/lsmon/codec.js (mechanical CJS -> ESM)
|
||||
// Text in LS server packets is the server's ANSI code page, NUL-terminated: CP949 (Korea), Big5 (HK/TW),
|
||||
// Windows-874 (Thai), GBK, Shift-JIS, Windows-1252. TextDecoder decodes all of them; for encoding the
|
||||
// reverse table is built once per code page from the decoder (no extra package).
|
||||
|
||||
const ENCODINGS = {
|
||||
big5: "Traditional Chinese (Big5 — HK / TW)",
|
||||
"euc-kr": "Korean (CP949)",
|
||||
"windows-874": "Thai (Windows-874)",
|
||||
gbk: "Simplified Chinese (GBK)",
|
||||
shift_jis: "Japanese (Shift-JIS)",
|
||||
"windows-1252": "Western (Windows-1252)",
|
||||
};
|
||||
|
||||
const decoders = new Map();
|
||||
const encoders = new Map();
|
||||
|
||||
function decoder(enc) {
|
||||
if (!decoders.has(enc)) decoders.set(enc, new TextDecoder(ENCODINGS[enc] ? enc : "windows-1252"));
|
||||
return decoders.get(enc);
|
||||
}
|
||||
|
||||
/** char → bytes for a code page, from decoding every single- and double-byte sequence. */
|
||||
function encoder(enc) {
|
||||
if (encoders.has(enc)) return encoders.get(enc);
|
||||
const map = new Map();
|
||||
const d = new TextDecoder(ENCODINGS[enc] ? enc : "windows-1252", { fatal: true });
|
||||
const tryBytes = (bytes) => {
|
||||
try {
|
||||
const s = d.decode(Uint8Array.from(bytes));
|
||||
if (s.length === 1 || (s.length === 2 && s.codePointAt(0) > 0xffff)) {
|
||||
if (!map.has(s) && s !== "�") map.set(s, Buffer.from(bytes));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
for (let b = 0; b < 0x80; b++) map.set(String.fromCharCode(b), Buffer.from([b]));
|
||||
const single = enc === "windows-874" || enc === "windows-1252";
|
||||
if (single) {
|
||||
for (let b = 0x80; b < 0x100; b++) tryBytes([b]);
|
||||
} else {
|
||||
for (let lead = 0x81; lead <= 0xfe; lead++) {
|
||||
for (let trail = 0x40; trail <= 0xfe; trail++) tryBytes([lead, trail]);
|
||||
}
|
||||
if (enc === "shift_jis") for (let b = 0xa1; b <= 0xdf; b++) tryBytes([b]); // half-width katakana
|
||||
}
|
||||
encoders.set(enc, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** String → bytes in the code page (characters it can't hold become "?"). */
|
||||
function encode(text, enc) {
|
||||
const map = encoder(enc);
|
||||
const parts = [];
|
||||
for (const ch of String(text ?? "")) parts.push(map.get(ch) || map.get("?"));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
const decode = (bytes, enc) => decoder(enc).decode(bytes);
|
||||
|
||||
/**
|
||||
* Web addition: a Latin (windows-1252) text table of a regional client still holds untranslated
|
||||
* Korean strings (e.g. Gemscool text_id.txt: "ÇÁ¸®¹Ì¾ö ÆÐŰÁö"). A string whose high bytes all pair
|
||||
* up as EUC-KR double bytes (lead and trail A1–FE: Hangul, Hanja, Korean symbols such as Ⅰ Ⅱ) is
|
||||
* read as Korean instead. Latin accents (é, ñ…) stand alone and keep the Latin reading.
|
||||
*/
|
||||
function looksKorean(bytes) {
|
||||
let pairs = 0;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i];
|
||||
if (b < 0x80) continue;
|
||||
const t = bytes[i + 1];
|
||||
if (b < 0xa1 || b > 0xfe || t === undefined || t < 0xa1 || t > 0xfe) return false;
|
||||
pairs++;
|
||||
i++;
|
||||
}
|
||||
return pairs > 0;
|
||||
}
|
||||
const decodeText = (bytes, enc) => (enc === "windows-1252" && looksKorean(bytes) ? decoder("euc-kr").decode(bytes) : decoder(enc).decode(bytes));
|
||||
const textDecoder = (enc) => ({ decode: (bytes) => decodeText(bytes, enc) });
|
||||
|
||||
export { encode, decode, decodeText, textDecoder, looksKorean, ENCODINGS };
|
||||
export default { encode, decode, decodeText, textDecoder, looksKorean, ENCODINGS };
|
||||
@@ -0,0 +1,149 @@
|
||||
// Ported from LS Tools desktop: main/models.js (mechanical CJS -> ESM)
|
||||
import assets from "./assets.js";
|
||||
import { iniXor } from "../core/tools.js";
|
||||
|
||||
// Which skeleton goes with a mesh or an animation. Meshes and animations only name bones, so:
|
||||
// 1. model configs (resource/model/*.txt) that list the mesh / animation name their skeleton;
|
||||
// 2. otherwise the skeleton whose bone names cover the most of the mesh's / track's bones; ties go
|
||||
// to the skeleton the game refers to most (config/*.ini, xml, model configs) — for hero gear
|
||||
// that is human_man.skl, set in the hero configs rather than in a model config.
|
||||
// The index is built once per source (≈3 000 model configs + ≈500 skeletons).
|
||||
|
||||
const indexes = new Map(); // source key → index
|
||||
const keyOf = (src) => `${src.kind}:${(src.folder || "").toLowerCase()}:${src.sessionId || ""}`;
|
||||
|
||||
function parseCfg(text) {
|
||||
const cfg = { skeleton: "", animations: [], meshes: [] };
|
||||
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim());
|
||||
let list = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (!l || l.startsWith(";") || l.startsWith("//")) continue;
|
||||
if (l === "#SKELETON") cfg.skeleton = (lines[++i] || "").toLowerCase();
|
||||
else if (l === "#ANIMATION_LIST") list = cfg.animations;
|
||||
else if (l === "#MESH_LIST") list = cfg.meshes;
|
||||
else if (l.startsWith("#")) list = null;
|
||||
else if (list) list.push(l.toLowerCase());
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Bone names of a .skl and the skeleton it links to (see LSF.parseSkl for the full reader). */
|
||||
function sklBones(b) {
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
let o = 8;
|
||||
const n = dv.getInt32(o, true);
|
||||
o += 4;
|
||||
const str = () => {
|
||||
const l = dv.getInt32(o, true);
|
||||
o += 4;
|
||||
const s = Buffer.from(b.buffer, b.byteOffset + o, l).toString("latin1").toLowerCase();
|
||||
o += l;
|
||||
return s;
|
||||
};
|
||||
const names = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
names.push(str());
|
||||
o += 28 + 28 + 64;
|
||||
str();
|
||||
const c = dv.getInt32(o, true);
|
||||
o += 4;
|
||||
for (let j = 0; j < c; j++) str();
|
||||
}
|
||||
const linked = o + 4 <= b.length ? str() : "";
|
||||
return { names, linked };
|
||||
}
|
||||
|
||||
// Web port: the index is built per part, only what a call needs ("models": the model configs;
|
||||
// "skl": skeleton bones; "refs": skeleton names in config/xml — the slow part, thousands of
|
||||
// files). "Used by" needs only the model configs. Progress goes to the page's loading overlay.
|
||||
const kindOf = (l) =>
|
||||
l.endsWith(".txt") && /(^|\/)model\//.test(l) ? "models" : /^(config|xml)\//.test(l) && /\.(ini|xml)$/.test(l) ? "refs" : l.endsWith(".skl") ? "skl" : null;
|
||||
|
||||
function build(src, need = ["models", "skl", "refs"]) {
|
||||
const key = keyOf(src);
|
||||
if (!indexes.has(key)) indexes.set(key, { byMesh: new Map(), byAni: new Map(), models: new Map(), skl: new Map(), refs: new Map(), done: new Set() });
|
||||
const idx = indexes.get(key);
|
||||
const todo = need.filter((p) => !idx.done.has(p));
|
||||
if (!todo.length) return idx;
|
||||
const ref = (name, w = 1) => idx.refs.set(name, (idx.refs.get(name) || 0) + w);
|
||||
const add = (map, k, v) => {
|
||||
if (!map.has(k)) map.set(k, new Set());
|
||||
map.get(k).add(v);
|
||||
};
|
||||
const items = assets.list(src).filter((it) => todo.includes(kindOf(it.path.toLowerCase())));
|
||||
let n = 0;
|
||||
for (const it of items) {
|
||||
globalThis.__lsProgress?.(n++, items.length);
|
||||
const l = it.path.toLowerCase();
|
||||
try {
|
||||
if (l.endsWith(".txt") && /(^|\/)model\//.test(l)) {
|
||||
const r = assets.readKey(src, it.key);
|
||||
const cfg = parseCfg(Buffer.from(r.bytes).toString("latin1"));
|
||||
const name = l.split("/").pop();
|
||||
idx.models.set(name, cfg);
|
||||
if (!cfg.skeleton) continue;
|
||||
ref(cfg.skeleton);
|
||||
for (const m of cfg.meshes) add(idx.byMesh, m, cfg.skeleton);
|
||||
for (const a of cfg.animations) add(idx.byAni, a, cfg.skeleton);
|
||||
} else if (/^(config|xml)\//.test(l) && /\.(ini|xml)$/.test(l)) {
|
||||
// skeleton names in the game configs (some .ini are XOR-scrambled)
|
||||
const raw = Buffer.from(assets.readKey(src, it.key).bytes);
|
||||
let text = raw.toString("latin1");
|
||||
if (!/\.skl/i.test(text) && l.endsWith(".ini")) text = iniXor(raw).toString("latin1");
|
||||
// the main configs (config/sp2_char.ini: the heroes) count more than mode / npc configs
|
||||
const w = /^config\/[^/]+\.ini$/.test(l) ? 5 : 1;
|
||||
for (const m of text.matchAll(/([\w.-]+\.skl)\b/gi)) ref(m[1].toLowerCase(), w);
|
||||
} else if (l.endsWith(".skl")) {
|
||||
const name = l.split("/").pop();
|
||||
if (!idx.skl.has(name)) {
|
||||
const s = sklBones(assets.readKey(src, it.key).bytes);
|
||||
idx.skl.set(name, Object.assign(new Set(s.names), { linked: s.linked }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[models] skipped", it.path, err.message);
|
||||
}
|
||||
}
|
||||
for (const p of todo) idx.done.add(p);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranked skeleton candidates for a mesh (stem, e.g. "npc_boarman_head") or an animation
|
||||
* ("public_delay.ani"), given its bone names. → [{ name, matched, total, fromModel }]
|
||||
*/
|
||||
function findSkeleton(src, { mesh, ani, bones = [] }) {
|
||||
const idx = build(src);
|
||||
const want = [...new Set(bones.map((b) => b.toLowerCase()))];
|
||||
const listed = new Set([...(mesh ? idx.byMesh.get(mesh.toLowerCase()) || [] : []), ...(ani ? idx.byAni.get(ani.toLowerCase()) || [] : [])]);
|
||||
const out = [];
|
||||
for (const [name, set] of idx.skl) {
|
||||
const matched = want.filter((b) => set.has(b)).length;
|
||||
if (!listed.has(name) && (!want.length || matched === 0)) continue;
|
||||
out.push({ name, matched, total: want.length, bones: set.size, fromModel: listed.has(name), refs: idx.refs.get(name) || 0, linked: set.linked || "" });
|
||||
}
|
||||
// listed by a model config first, then the best cover, then the standard hero skeletons (hero
|
||||
// gear only names hand / spine bones, which every biped has), then the most used, base
|
||||
// skeletons before linked variants, then the tightest fit
|
||||
const hero = (n) => (/^human_(man|woman)\.skl$/.test(n) ? 1 : 0);
|
||||
out.sort((a, b) => b.fromModel - a.fromModel || b.matched - a.matched || hero(b.name) - hero(a.name) || b.refs - a.refs || !!a.linked - !!b.linked || a.bones - b.bones || a.name.localeCompare(b.name));
|
||||
return out.slice(0, 30);
|
||||
}
|
||||
|
||||
/** Model configs that use an animation or mesh (for "used by"). */
|
||||
function usedBy(src, { mesh, ani }) {
|
||||
const idx = build(src, ["models"]);
|
||||
const out = [];
|
||||
for (const [name, cfg] of idx.models) {
|
||||
if ((mesh && cfg.meshes.includes(mesh.toLowerCase())) || (ani && cfg.animations.includes(ani.toLowerCase()))) out.push(name);
|
||||
}
|
||||
return out.slice(0, 50);
|
||||
}
|
||||
|
||||
function forget(src) {
|
||||
indexes.delete(keyOf(src));
|
||||
}
|
||||
|
||||
export { findSkeleton, usedBy, forget };
|
||||
export default { findSkeleton, usedBy, forget };
|
||||
@@ -0,0 +1,667 @@
|
||||
// Ported from LS Tools desktop: main/quests.js (mechanical CJS -> ESM)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import assets from "./assets.js";
|
||||
import items from "./items.js";
|
||||
import codec from "./lsmon/codec.js";
|
||||
import spec from "./questspec.js";
|
||||
import { IniDoc, TextDoc } from "./inidoc.js";
|
||||
|
||||
// Quests tab: quests, their rewards, help texts, guides and the daily / weekly / monthly missions,
|
||||
// read from the game's packs (config/*.ini in rs.iop, texts in the string table of map.iop) and
|
||||
// written back in place: only changed lines are rewritten (inidoc.js). The game server reads the
|
||||
// same files, so "Export for server" copies them out. What each key means: docs/quests.md.
|
||||
|
||||
const FILES = {
|
||||
quest: { name: "sp2_quest_info.ini", max: ["common", "max_quest"], prefix: "quest" },
|
||||
present: { name: "sp2_quest_present.ini", max: ["common", "max_present"], prefix: "Present" },
|
||||
help: { name: "sp2_quest_help.ini", max: ["common", "max_help"], prefix: "help" },
|
||||
guide: { name: "sp2_quest_guide.ini", max: ["Common", "MaxGuide"], prefix: "Guide" },
|
||||
daily: { name: "sp2_daily_mission.ini", max: ["common", "max_count"], prefix: "date", mission: 1 },
|
||||
weekly: { name: "sp2_weekly_mission.ini", max: ["common", "max_count"], prefix: "date", mission: 2 },
|
||||
monthly: { name: "sp2_monthly_mission.ini", max: ["common", "max_count"], prefix: "date", mission: 3 },
|
||||
};
|
||||
const SERVER_FILES = ["quest", "present", "daily", "weekly", "monthly"];
|
||||
const HELP_DONE = 100000;
|
||||
const HELP_GOAL = 200000;
|
||||
|
||||
const num = (v) => {
|
||||
const n = parseInt(String(v ?? "").trim(), 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const colorOf = (v) => {
|
||||
const m = /^0x([0-9a-f]{6,8})$/i.exec(String(v || "").trim());
|
||||
return m ? `#${m[1].slice(-6)}` : null;
|
||||
};
|
||||
const nf = (n) => Number(n || 0).toLocaleString("en-US");
|
||||
const stemOf = (fileId) => FILES[fileId].name.replace(/\.ini$/i, "").toLowerCase();
|
||||
|
||||
const cache = new Map();
|
||||
const srcKey = (src) => JSON.stringify(src);
|
||||
|
||||
class QuestDb {
|
||||
constructor(src, text) {
|
||||
this.src = src;
|
||||
this.text = text; // { file, encoding, key } or null
|
||||
this.files = {};
|
||||
this.tdoc = undefined;
|
||||
this.kr = new TextDecoder("euc-kr");
|
||||
}
|
||||
|
||||
// ---------- files ----------
|
||||
doc(id) {
|
||||
if (!(id in this.files)) {
|
||||
const name = FILES[id].name;
|
||||
const hit = assets.readKey(this.src, `rs.iop|config/${name}`) || assets.readPath(this.src, `config/${name}`);
|
||||
this.files[id] = hit ? { doc: new IniDoc(Buffer.from(hit.bytes)), where: hit.where, dirty: false } : null;
|
||||
}
|
||||
return this.files[id]?.doc || null;
|
||||
}
|
||||
|
||||
textDoc() {
|
||||
if (this.tdoc === undefined) {
|
||||
const hit = this.text ? assets.readKey(this.src, this.text.key) : null;
|
||||
this.tdoc = hit ? { doc: new TextDoc(Buffer.from(hit.bytes)), where: hit.where, dirty: false } : null;
|
||||
}
|
||||
return this.tdoc?.doc || null;
|
||||
}
|
||||
|
||||
/** A value as the player sees it: STR(n) from the string table, other text in the INI's own code page. */
|
||||
str(fileId, section, raw) {
|
||||
if (raw === undefined || raw === null || raw === "") return "";
|
||||
const m = /STR\(\s*(\d+)/i.exec(raw);
|
||||
if (m) {
|
||||
const t = this.textDoc()?.get(`ini_${stemOf(fileId)}::${section}_${m[1]}`);
|
||||
return t === undefined ? raw : codec.decodeText(Buffer.from(t, "latin1"), this.text.encoding).replace(/\\r/g, "").replace(/\\n/g, "\n");
|
||||
}
|
||||
return /[\x80-\xff]/.test(raw) ? this.kr.decode(Buffer.from(raw, "latin1")) : raw;
|
||||
}
|
||||
|
||||
isText(raw) {
|
||||
return /STR\(\s*\d+/i.test(raw || "") || /[\x80-\xff]/.test(raw || "");
|
||||
}
|
||||
|
||||
// ---------- quests ----------
|
||||
quests() {
|
||||
if (this._quests) return this._quests;
|
||||
const d = this.doc("quest");
|
||||
if (!d) return (this._quests = []);
|
||||
const max = num(d.get("common", "max_quest"));
|
||||
const out = [];
|
||||
for (const s of d.order) {
|
||||
const m = /^quest(\d+)$/i.exec(s.name);
|
||||
if (!m) continue;
|
||||
const g = (k) => d.get(s.name, k);
|
||||
const maxSub = num(g("max_sub_quest"));
|
||||
const subs = [];
|
||||
for (let j = 1; j <= maxSub; j++) {
|
||||
const p = `sub${j}_`;
|
||||
subs.push({
|
||||
j,
|
||||
title: this.str("quest", s.name, g(`${p}title`)),
|
||||
icon: g(`${p}icon`) || "",
|
||||
perform: num(g(`${p}perform_type`)),
|
||||
complete: num(g(`${p}complete_value`)),
|
||||
oneday: num(g(`${p}oneday_style`)) !== 0,
|
||||
repeat: num(g(`${p}repeat_style`)) !== 0,
|
||||
start: this.dateOf(g, p, "start"),
|
||||
end: this.dateOf(g, p, "end"),
|
||||
});
|
||||
}
|
||||
out.push({ section: s.name, n: Number(m[1]), main: num(g("main_index")), cls: g("class_name") || "", subs, ignored: Number(m[1]) > max });
|
||||
}
|
||||
this._quests = out;
|
||||
this._byMain = new Map();
|
||||
for (const q of out) if (!this._byMain.has(q.main)) this._byMain.set(q.main, q);
|
||||
return out;
|
||||
}
|
||||
|
||||
dateOf(g, p, which) {
|
||||
const y = num(g(`${p}${which}_year`));
|
||||
if (!y) return null;
|
||||
const pad = (v) => String(num(v)).padStart(2, "0");
|
||||
return `${y}-${pad(g(`${p}${which}_month`))}-${pad(g(`${p}${which}_date`))} ${pad(g(`${p}${which}_hour`))}:00`;
|
||||
}
|
||||
|
||||
byMain(main) {
|
||||
this.quests();
|
||||
return this._byMain.get(main) || null;
|
||||
}
|
||||
|
||||
// ---------- rewards ----------
|
||||
presents() {
|
||||
if (this._presents) return this._presents;
|
||||
const d = this.doc("present");
|
||||
const map = new Map();
|
||||
const dups = [];
|
||||
if (d) {
|
||||
for (const s of d.order) {
|
||||
if (!/^present\d+$/i.test(s.name)) continue;
|
||||
const g = (k) => d.get(s.name, k);
|
||||
const index = num(g("RewardIndex"));
|
||||
if (!index) continue;
|
||||
if (map.has(index)) {
|
||||
dups.push({ index, section: s.name, first: map.get(index).section });
|
||||
continue;
|
||||
}
|
||||
map.set(index, this.presentOf(s.name, g));
|
||||
}
|
||||
}
|
||||
this._presents = map;
|
||||
this._presentDups = dups;
|
||||
return map;
|
||||
}
|
||||
|
||||
presentOf(section, g) {
|
||||
const type = num(g("PresentType"));
|
||||
const v1 = num(g("PresentValue1"));
|
||||
const v2 = num(g("PresentValue2"));
|
||||
const p = {
|
||||
section,
|
||||
index: num(g("RewardIndex")),
|
||||
type,
|
||||
v1,
|
||||
v2,
|
||||
direct: num(g("PresentDirect")) !== 0,
|
||||
ment: num(g("PresentMent")),
|
||||
period: num(g("PresentPeriod")),
|
||||
alarm: num(g("PresentAlarm")),
|
||||
sender: this.str("present", section, g("PresentSendID")),
|
||||
typeName: spec.PRESENT_TYPES[type]?.name || `Type ${type}`,
|
||||
};
|
||||
Object.assign(p, this.presentLabel(type, v1, v2));
|
||||
return p;
|
||||
}
|
||||
|
||||
lookup() {
|
||||
if (!this._lookup) {
|
||||
try {
|
||||
this._lookup = items.lookup(this.src, this.text?.file || null);
|
||||
} catch {
|
||||
this._lookup = {};
|
||||
}
|
||||
}
|
||||
return this._lookup;
|
||||
}
|
||||
|
||||
/** What a present gives, in words: { label, icon } */
|
||||
presentLabel(type, v1, v2) {
|
||||
const L = this.lookup();
|
||||
const find = (cat, id) => L[cat]?.get(id) || null;
|
||||
const named = (hit, fallback) => (hit?.name && !/^STR\(/i.test(hit.name) ? hit.name : fallback);
|
||||
const hours = (h) => (h ? `${nf(h)} h` : "permanent");
|
||||
switch (type) {
|
||||
case 1: {
|
||||
const h = find("hero", 700000 + v1);
|
||||
return { label: `${named(h, `Hero class ${v1}`)} (${hours(v2)})`, icon: h?.icon || "" };
|
||||
}
|
||||
case 3: {
|
||||
const h = find("etc", v1);
|
||||
return { label: `${named(h, `Etc item ${v1}`)} × ${nf(v2)}`, icon: h?.icon || "" };
|
||||
}
|
||||
case 4:
|
||||
return { label: `${nf(v1)} peso`, icon: "" };
|
||||
case 5: {
|
||||
const h = find("equip", v1);
|
||||
const reinforce = Math.floor(v2 / 10000) % 10000;
|
||||
return { label: `${named(h, `Gear ${v1}`)}${reinforce ? ` +${reinforce}` : ""} (${hours(v2 % 10000)})`, icon: h?.icon || "" };
|
||||
}
|
||||
case 6: {
|
||||
const h = find("equipbox", v1);
|
||||
return { label: named(h, `Gear box ${v1}`), icon: h?.icon || "" };
|
||||
}
|
||||
case 8:
|
||||
return { label: `${nf(v1)} grade exp`, icon: "" };
|
||||
case 9: {
|
||||
const h = find("medal", v1);
|
||||
return { label: `${named(h, `Medal ${v1}`)} (${hours(v2)})`, icon: h?.icon || "" };
|
||||
}
|
||||
case 12: {
|
||||
const h = find("costume", v1);
|
||||
return { label: `${named(h, `Costume ${v1}`)} (${hours(v2 % 10000)})`, icon: h?.icon || "" };
|
||||
}
|
||||
case 13:
|
||||
return { label: `${nf(v1)} bonus cash`, icon: "" };
|
||||
case 15: {
|
||||
const h = find("costumebox", v1);
|
||||
return { label: named(h, `Costume box ${v1}`), icon: h?.icon || "" };
|
||||
}
|
||||
default:
|
||||
return { label: `${spec.PRESENT_TYPES[type]?.name || `Type ${type}`} ${v1} / ${v2}`, icon: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Which quests / missions give each reward: Map(RewardIndex → count) */
|
||||
presentUse() {
|
||||
const use = new Map();
|
||||
const add = (i) => i && use.set(i, (use.get(i) || 0) + 1);
|
||||
const d = this.doc("quest");
|
||||
if (d) {
|
||||
for (const q of this.quests()) {
|
||||
for (const s of q.subs) {
|
||||
const n = Math.min(num(d.get(q.section, `sub${s.j}_max_reward`)), 5);
|
||||
for (let k = 1; k <= n; k++) add(num(d.get(q.section, `sub${s.j}_reward_present${k}`)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of ["daily", "weekly", "monthly"]) for (const date of this.missions(id)) for (const m of date.missions) add(m.present);
|
||||
return use;
|
||||
}
|
||||
|
||||
// ---------- help / guide ----------
|
||||
helpIndex() {
|
||||
if (this._help) return this._help;
|
||||
const d = this.doc("help");
|
||||
const map = new Map();
|
||||
if (d) for (const s of d.order) if (/^help\d+$/i.test(s.name)) {
|
||||
const i = num(d.get(s.name, "index"));
|
||||
if (i && !map.has(i)) map.set(i, s.name);
|
||||
}
|
||||
return (this._help = map);
|
||||
}
|
||||
|
||||
help(index) {
|
||||
const section = this.helpIndex().get(index);
|
||||
if (!section) return { index, section: null, lines: [] };
|
||||
const d = this.doc("help");
|
||||
const g = (k) => d.get(section, k);
|
||||
const lines = [];
|
||||
const max = num(g("max_line"));
|
||||
for (let l = 1; l <= max; l++) {
|
||||
const runs = [];
|
||||
const count = Math.max(num(g(`line${l}_count`)), 1);
|
||||
for (let k = 1; k <= count; k++) {
|
||||
const t = g(`line${l}_text${k}`);
|
||||
if (t === undefined) continue;
|
||||
runs.push({ text: this.str("help", section, t), color: colorOf(g(`line${l}_color${k}`)), space: num(g(`line${l}_space${k}`)) });
|
||||
}
|
||||
lines.push({ runs, image: g(`line${l}_image`) || "" });
|
||||
}
|
||||
return { index, section, lines };
|
||||
}
|
||||
|
||||
guides() {
|
||||
if (this._guides) return this._guides;
|
||||
const d = this.doc("guide");
|
||||
const map = new Map();
|
||||
if (d) for (const s of d.order) if (/^guide\d+$/i.test(s.name)) {
|
||||
const g = (k) => d.get(s.name, k);
|
||||
const key = `${num(g("MainIndex"))}.${num(g("SubIndex"))}`;
|
||||
const data = [];
|
||||
for (let i = 1; i <= num(g("MaxGuideData")); i++) data.push(g(`GuideData${i}`) || "");
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push({ section: s.name, data });
|
||||
}
|
||||
return (this._guides = map);
|
||||
}
|
||||
|
||||
// ---------- missions ----------
|
||||
missions(id) {
|
||||
this._missions = this._missions || {};
|
||||
if (this._missions[id]) return this._missions[id];
|
||||
const d = this.doc(id);
|
||||
const out = [];
|
||||
if (d) {
|
||||
const presents = this.presents();
|
||||
for (const s of d.order) {
|
||||
if (!/^date\d+$/i.test(s.name)) continue;
|
||||
const g = (k) => d.get(s.name, k);
|
||||
const missions = [];
|
||||
for (let k = 1; k < 1000; k++) {
|
||||
const code = num(g(`mission${k}_code`));
|
||||
if (!code) break;
|
||||
const type = num(g(`mission${k}_type`));
|
||||
const titles = [1, 2].map((t) => {
|
||||
const runs = [];
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const raw = g(`mission${k}_title${t}_text${i}`);
|
||||
if (raw === undefined) break;
|
||||
runs.push({ text: this.str(id, s.name, raw).replace(/#/g, " "), color: colorOf(g(`mission${k}_title${t}_color${i}`)) });
|
||||
}
|
||||
return runs;
|
||||
});
|
||||
const present = num(g(`mission${k}_present`));
|
||||
missions.push({
|
||||
k,
|
||||
code,
|
||||
type,
|
||||
typeName: spec.MISSION_TYPES[type]?.name || `Type ${type}`,
|
||||
values: g(`mission${k}_values`) || "",
|
||||
present,
|
||||
reward: presents.get(present) || null,
|
||||
icon: g(`mission${k}_icon`) || "",
|
||||
titles,
|
||||
presentText: [1, 2, 3, 4].map((i) => this.str(id, s.name, g(`mission${k}_present_text${i}`))).filter(Boolean),
|
||||
});
|
||||
}
|
||||
out.push({ section: s.name, n: num(/\d+/.exec(s.name)[0]), start: g("start_date") || "", end: g("end_date") || "", missions });
|
||||
}
|
||||
}
|
||||
return (this._missions[id] = out);
|
||||
}
|
||||
|
||||
// ---------- checks ----------
|
||||
/** Problems of one quest section: [{ sev, sub, msg }] */
|
||||
questProblems(q) {
|
||||
const d = this.doc("quest");
|
||||
const out = [];
|
||||
const add = (sev, sub, msg) => out.push({ sev, sub, msg });
|
||||
const cls = spec.CLASSES.find((c) => c.name.toLowerCase() === q.cls.toLowerCase());
|
||||
if (!cls) add("error", 0, q.cls ? `Unknown class ${q.cls}: the client and server skip this quest.` : "No class_name: the quest is skipped.");
|
||||
if (!q.main) add("error", 0, "main_index is missing or 0.");
|
||||
else if (q.main > 65535) add("warn", 0, `main_index ${q.main} is above 65535.`);
|
||||
if (!q.subs.length) add("error", 0, "max_sub_quest is 0: nothing is loaded.");
|
||||
if (q.ignored) add("warn", 0, `[${q.section}] is above max_quest: it isn't read.`);
|
||||
if (d.get(q.section, `sub${q.subs.length + 1}_title`) !== undefined) add("warn", 0, `sub${q.subs.length + 1}_* keys exist but max_sub_quest is ${q.subs.length}.`);
|
||||
const presents = this.presents();
|
||||
const help = this.helpIndex();
|
||||
for (const s of q.subs) {
|
||||
const g = (k) => d.get(q.section, `sub${s.j}_${k}`);
|
||||
const maxReward = num(g("max_reward"));
|
||||
if (maxReward > 5) add("error", s.j, `max_reward ${maxReward}: at most 5 (the server reads past its array).`);
|
||||
for (let k = 1; k <= Math.min(maxReward, 5); k++) {
|
||||
const r = num(g(`reward_present${k}`));
|
||||
if (!r) add("warn", s.j, `reward_present${k} is empty.`);
|
||||
else if (!presents.has(r)) add("error", s.j, `reward_present${k} = ${r}: no such RewardIndex in sp2_quest_present.ini.`);
|
||||
}
|
||||
if (num(g("rewardselect_style")) && num(g("rewardselect_num")) > maxReward) add("warn", s.j, "rewardselect_num is more than max_reward.");
|
||||
// custom values must be contiguous
|
||||
let gap = 0;
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const v = g(`custom_value${i}`);
|
||||
if (v === undefined) gap = gap || i;
|
||||
else if (gap) {
|
||||
add("error", s.j, `custom_value${gap} is missing: values after it (custom_value${i}) aren't read.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pm = num(g("prev_main_index"));
|
||||
const ps = num(g("prev_sub_index"));
|
||||
if (pm > 0 && ps > 0) {
|
||||
const pq = this.byMain(pm);
|
||||
if (!pq || ps > pq.subs.length) add("error", s.j, `Needs quest ${pm}.${ps}, which doesn't exist: it can never be offered.`);
|
||||
}
|
||||
const nm = num(g("next_main_index"));
|
||||
const ns = num(g("next_sub_index"));
|
||||
if (nm > 0 && ns > 0) {
|
||||
const nq = this.byMain(nm);
|
||||
if (!nq || ns > nq.subs.length) add("warn", s.j, `Next quest ${nm}.${ns} doesn't exist.`);
|
||||
}
|
||||
// help H = in progress, H+100000 = completed, H+200000 = objective (HK uses 3xxxxx bases too)
|
||||
const h = num(g("help"));
|
||||
if (h > 0 && help.size && !help.has(h)) add("warn", s.j, `help ${h} isn't in sp2_quest_help.ini.`);
|
||||
const perform = num(g("perform_type"));
|
||||
if (perform === 2) {
|
||||
if (!num(g("start_year")) || !num(g("end_year"))) add("error", s.j, "Event quest (perform_type 2) without start / end dates.");
|
||||
else if (num(g("start_year")) < 2010) add("error", s.j, "Start year before 2010.");
|
||||
else if (s.end && s.start && s.end <= s.start) add("warn", s.j, "The event ends before it starts.");
|
||||
} else if (perform !== 1) add("warn", s.j, `perform_type ${perform}: use 1 (normal) or 2 (event).`);
|
||||
if (g("oneday_cnt") !== undefined) add("warn", s.j, "oneday_cnt is misread by the server: don't use it.");
|
||||
if (/^QuestEtcItemUse$/i.test(q.cls) && num(g("custom_value3")) === 0) add("warn", s.j, "QuestEtcItemUse with no item codes (custom_value3 = 0) never completes.");
|
||||
if (/^QuestExtraItemEquipKo$/i.test(q.cls) && num(g("custom_value4")) === 0) add("warn", s.j, "QuestExtraItemEquipKo with gear code 0 never completes.");
|
||||
if (!num(g("complete_value")) && cls && cls.qc) add("warn", s.j, `complete_value is 0 (this class usually uses ${cls.qc}: ${spec.COMPLETE[cls.qc]}).`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every problem in the quest files. → [{ sev, file, section, sub, msg }] */
|
||||
check() {
|
||||
const out = [];
|
||||
const seen = new Map();
|
||||
for (const q of this.quests()) {
|
||||
if (q.main && seen.has(q.main)) out.push({ sev: "error", file: "quest", section: q.section, sub: 0, msg: `main_index ${q.main} is also used by [${seen.get(q.main)}]: client and server disagree on it.` });
|
||||
else seen.set(q.main, q.section);
|
||||
for (const p of this.questProblems(q)) out.push({ ...p, file: "quest", section: q.section });
|
||||
}
|
||||
this.presents();
|
||||
for (const x of this._presentDups) out.push({ sev: "warn", file: "present", section: x.section, sub: 0, msg: `RewardIndex ${x.index} is also in [${x.first}]: this copy is ignored.` });
|
||||
for (const p of this.presents().values()) if (!spec.PRESENT_TYPES[p.type]) out.push({ sev: "error", file: "present", section: p.section, sub: 0, msg: `Unknown PresentType ${p.type}.` });
|
||||
for (const id of ["daily", "weekly", "monthly"]) {
|
||||
const codes = new Map();
|
||||
for (const date of this.missions(id)) {
|
||||
for (const m of date.missions) {
|
||||
if (codes.has(m.code) && codes.get(m.code) !== date.section) out.push({ sev: "warn", file: id, section: date.section, sub: m.k, msg: `Mission code ${m.code} is also used in [${codes.get(m.code)}] (progress is stored by code).` });
|
||||
else codes.set(m.code, date.section);
|
||||
if (m.present && !m.reward) out.push({ sev: "error", file: id, section: date.section, sub: m.k, msg: `mission${m.k}_present = ${m.present}: no such RewardIndex.` });
|
||||
if (!spec.MISSION_TYPES[m.type]) out.push({ sev: "error", file: id, section: date.section, sub: m.k, msg: `Unknown mission type ${m.type}.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- one section in full ----------
|
||||
/** Raw keys of any section + the player-visible text of its text keys. */
|
||||
sectionData(fileId, section) {
|
||||
const d = this.doc(fileId);
|
||||
if (!d?.has(section)) return null;
|
||||
const entries = d.entries(section);
|
||||
const texts = {};
|
||||
for (const [k, v] of entries) if (this.isText(v)) texts[k.toLowerCase()] = this.str(fileId, section, v);
|
||||
return { file: fileId, section: d.section(section).name, entries, texts };
|
||||
}
|
||||
|
||||
questDetail(section) {
|
||||
const q = this.quests().find((x) => x.section.toLowerCase() === String(section).toLowerCase());
|
||||
if (!q) return null;
|
||||
const d = this.doc("quest");
|
||||
const presents = this.presents();
|
||||
const guides = this.guides();
|
||||
const subs = q.subs.map((s) => {
|
||||
const g = (k) => d.get(q.section, `sub${s.j}_${k}`);
|
||||
const rewards = [];
|
||||
for (let k = 1; k <= Math.min(num(g("max_reward")), 5); k++) {
|
||||
const i = num(g(`reward_present${k}`));
|
||||
rewards.push({ k, index: i, present: presents.get(i) || null });
|
||||
}
|
||||
const h = num(g("help"));
|
||||
const chain = (m, sb) => {
|
||||
if (!(m > 0 && sb > 0)) return null;
|
||||
const t = this.byMain(m);
|
||||
return { main: m, sub: sb, section: t?.section || null, title: t?.subs[sb - 1]?.title || null };
|
||||
};
|
||||
return {
|
||||
j: s.j,
|
||||
rewards,
|
||||
help: h > 0 ? [this.help(h), this.help(h + HELP_DONE), this.help(h + HELP_GOAL)].map((x, i) => ({ ...x, role: ["In progress", "Completed", "Objective"][i] })) : [],
|
||||
guides: guides.get(`${q.main}.${s.j}`) || [],
|
||||
prev: chain(num(g("prev_main_index")), num(g("prev_sub_index"))),
|
||||
next: chain(num(g("next_main_index")), num(g("next_sub_index"))),
|
||||
};
|
||||
});
|
||||
return { ...this.sectionData("quest", q.section), quest: { section: q.section, n: q.n, main: q.main, cls: q.cls }, subs, problems: this.questProblems(q) };
|
||||
}
|
||||
|
||||
// ---------- numbers for new things ----------
|
||||
next() {
|
||||
const qs = this.quests();
|
||||
const mains = new Set(qs.map((q) => q.main));
|
||||
let main = Math.max(0, ...qs.map((q) => (q.main <= 65535 ? q.main : 0))) + 1;
|
||||
while (mains.has(main)) main++;
|
||||
const presents = [...this.presents().values()];
|
||||
const pd = this.doc("present");
|
||||
// a new help base needs H, H+100000 and H+200000 free
|
||||
const helps = this.helpIndex();
|
||||
let help = Math.max(0, ...[...helps.keys()].filter((i) => i < HELP_DONE)) + 1;
|
||||
while (helps.has(help) || helps.has(help + HELP_DONE) || helps.has(help + HELP_GOAL)) help++;
|
||||
const maxSection = (id) => {
|
||||
const d = this.doc(id);
|
||||
if (!d) return 0;
|
||||
return Math.max(0, ...d.order.map((s) => num(new RegExp(`^${FILES[id].prefix}(\\d+)$`, "i").exec(s.name)?.[1])));
|
||||
};
|
||||
return {
|
||||
questSection: `quest${maxSection("quest") + 1}`,
|
||||
main,
|
||||
presentSection: pd ? `Present${maxSection("present") + 1}` : null,
|
||||
rewardIndex: Math.max(0, ...presents.map((p) => p.index)) + 1,
|
||||
help,
|
||||
missionSection: Object.fromEntries(["daily", "weekly", "monthly"].map((id) => [id, `date${maxSection(id) + 1}`])),
|
||||
missionCode: Object.fromEntries(["daily", "weekly", "monthly"].map((id) => [id, Math.max(0, ...this.missions(id).flatMap((x) => x.missions.map((m) => m.code))) + 1])),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- writing ----------
|
||||
nextStr(d, section, pending) {
|
||||
let n = 0;
|
||||
const scan = (v) => {
|
||||
const m = /STR\(\s*(\d+)/i.exec(v || "");
|
||||
if (m) n = Math.max(n, Number(m[1]));
|
||||
};
|
||||
for (const [, v] of d.has(section) ? d.entries(section) : []) scan(v);
|
||||
for (const v of Object.values(pending)) scan(v);
|
||||
return n + 1;
|
||||
}
|
||||
|
||||
setText(fileId, section, n, text, warnings) {
|
||||
const t = this.textDoc();
|
||||
if (!t) throw new Error("No string table to write the text into.");
|
||||
const enc = this.text.encoding;
|
||||
const clean = String(text).replace(/\r/g, "").replace(/\n/g, "\\n");
|
||||
const bytes = codec.encode(clean, enc);
|
||||
if (codec.decode(bytes, enc) !== clean) warnings.push(`Some characters of "${String(text).slice(0, 30)}" don't exist in ${enc} and became "?".`);
|
||||
t.set(`${t.iniPrefix()}${stemOf(fileId)}::${section}_${n}`, bytes.toString("latin1"));
|
||||
this.tdoc.dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One change to one section of a file:
|
||||
* { file, section, create?, remove?, values: { key: raw | null }, texts: { key: "text" | "" } }
|
||||
* texts go to the string table (the key becomes / stays STR(n)); "" removes the key.
|
||||
* → { warnings }
|
||||
*/
|
||||
apply(change) {
|
||||
const f = FILES[change.file];
|
||||
if (!f) throw new Error(`Unknown file ${change.file}`);
|
||||
const d = this.doc(change.file);
|
||||
if (!d) throw new Error(`${f.name} isn't in the packs.`);
|
||||
const section = change.section;
|
||||
const warnings = [];
|
||||
if (change.remove) {
|
||||
d.removeSection(section);
|
||||
} else {
|
||||
if (change.create) d.addSection(section);
|
||||
else if (!d.has(section)) throw new Error(`[${section}] isn't in ${f.name}.`);
|
||||
const vals = { ...(change.values || {}) };
|
||||
for (const [key, text] of Object.entries(change.texts || {})) {
|
||||
if (text === "" || text === null) {
|
||||
vals[key] = null;
|
||||
continue;
|
||||
}
|
||||
const raw = key in vals ? vals[key] : d.get(section, key);
|
||||
let n = /STR\(\s*(\d+)/i.exec(raw || "")?.[1];
|
||||
if (!n) {
|
||||
n = this.nextStr(d, section, vals);
|
||||
vals[key] = `STR(${n})`;
|
||||
}
|
||||
this.setText(change.file, section, n, text, warnings);
|
||||
}
|
||||
d.update(section, vals);
|
||||
// the client reads sections 1..max: keep a new section inside
|
||||
const m = new RegExp(`^${f.prefix}(\\d+)$`, "i").exec(section);
|
||||
if (m && f.max && d.has(f.max[0]) && Number(m[1]) > num(d.get(f.max[0], f.max[1]))) d.update(f.max[0], { [f.max[1]]: m[1] });
|
||||
}
|
||||
this.files[change.file].dirty = true;
|
||||
this.forgetParsed();
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
forgetParsed() {
|
||||
this._quests = this._byMain = this._presents = this._help = this._guides = this._missions = null;
|
||||
}
|
||||
|
||||
/** Writes every changed file back into its pack. → [written file names] */
|
||||
async flush() {
|
||||
const written = [];
|
||||
for (const [id, f] of Object.entries(this.files)) {
|
||||
if (!f?.dirty) continue;
|
||||
await assets.writeBack(f.where, f.doc.toBuffer());
|
||||
f.dirty = false;
|
||||
written.push(FILES[id].name);
|
||||
}
|
||||
if (this.tdoc?.dirty) {
|
||||
await assets.writeBack(this.tdoc.where, this.tdoc.doc.toBuffer());
|
||||
this.tdoc.dirty = false;
|
||||
written.push(this.text.file);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/** The files the server needs, as they are now, into a folder. */
|
||||
exportTo(folder) {
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
const out = [];
|
||||
for (const id of SERVER_FILES) {
|
||||
const d = this.doc(id);
|
||||
if (!d) continue;
|
||||
fs.writeFileSync(path.join(folder, FILES[id].name), d.toBuffer());
|
||||
out.push(FILES[id].name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API for the window ----------
|
||||
|
||||
function dbOf(src, textFile, { reload = false } = {}) {
|
||||
const texts = items.textFiles(src);
|
||||
const text = texts.find((t) => t.file === textFile) || texts[0] || null;
|
||||
const key = `${srcKey(src)}|${text?.file || ""}`;
|
||||
if (reload) {
|
||||
assets.reload(src);
|
||||
items.cacheReset(src);
|
||||
for (const k of [...cache.keys()]) if (k.startsWith(srcKey(src))) cache.delete(k);
|
||||
}
|
||||
if (!cache.has(key)) cache.set(key, new QuestDb(src, text));
|
||||
return { db: cache.get(key), texts, text };
|
||||
}
|
||||
|
||||
const light = (q) => ({ section: q.section, n: q.n, main: q.main, cls: q.cls, ignored: q.ignored, subs: q.subs });
|
||||
|
||||
function load(src, { textFile = null, reload = false } = {}) {
|
||||
const { db, texts, text } = dbOf(src, textFile, { reload });
|
||||
const missing = Object.keys(FILES).filter((id) => !db.doc(id)).map((id) => FILES[id].name);
|
||||
const quests = db.quests();
|
||||
const problems = new Map();
|
||||
for (const p of db.check()) if (p.file === "quest") problems.set(p.section, (problems.get(p.section) || 0) + (p.sev === "error" ? 1000 : 1));
|
||||
return {
|
||||
texts: texts.map(({ file, label }) => ({ file, label })),
|
||||
text: text?.file || null,
|
||||
missing,
|
||||
quests: quests.map((q) => ({ ...light(q), errors: Math.floor((problems.get(q.section) || 0) / 1000), warnings: (problems.get(q.section) || 0) % 1000 })),
|
||||
next: db.next(),
|
||||
};
|
||||
}
|
||||
|
||||
const call = (src, textFile, fn) => fn(dbOf(src, textFile).db);
|
||||
|
||||
const __export_spec = () => ({ classes: spec.CLASSES, enums: spec.ENUMS, presentTypes: spec.PRESENT_TYPES, missionTypes: spec.MISSION_TYPES, subFields: spec.SUB_FIELDS, files: Object.fromEntries(Object.entries(FILES).map(([k, v]) => [k, v.name])) });
|
||||
const __export_quest = (src, t, section) => call(src, t, (db) => db.questDetail(section));
|
||||
const __export_section = (src, t, file, section) => call(src, t, (db) => db.sectionData(file, section));
|
||||
const __export_presents = (src, t) =>
|
||||
call(src, t, (db) => {
|
||||
const use = db.presentUse();
|
||||
return [...db.presents().values()].map((p) => ({ ...p, used: use.get(p.index) || 0 }));
|
||||
});
|
||||
const __export_presentLabel = (src, t, type, v1, v2) => call(src, t, (db) => db.presentLabel(num(type), num(v1), num(v2)));
|
||||
const __export_missions = (src, t, id) => call(src, t, (db) => db.missions(id));
|
||||
const __export_help = (src, t, index) => call(src, t, (db) => db.help(num(index)));
|
||||
const __export_check = (src, t) => call(src, t, (db) => db.check());
|
||||
const __export_next = (src, t) => call(src, t, (db) => db.next());
|
||||
const __export_pickLists = (src, t) => items.pickLists(src, dbOf(src, t).text?.file || null);
|
||||
const __export_save = async (src, t, changes) => {
|
||||
const { db } = dbOf(src, t);
|
||||
const warnings = [];
|
||||
try {
|
||||
for (const c of changes) warnings.push(...db.apply(c).warnings);
|
||||
const written = await db.flush();
|
||||
return { warnings, written };
|
||||
} catch (err) {
|
||||
// half-applied changes stay out: the next read starts from the packs again
|
||||
for (const k of [...cache.keys()]) if (k.startsWith(srcKey(src))) cache.delete(k);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const __export_exportTo = (src, t, folder) => call(src, t, (db) => db.exportTo(folder));
|
||||
export { __export_spec as spec, load, __export_quest as quest, __export_section as section, __export_presents as presents, __export_presentLabel as presentLabel, __export_missions as missions, __export_help as help, __export_check as check, __export_next as next, __export_pickLists as pickLists, __export_save as save, __export_exportTo as exportTo };
|
||||
export default { spec: __export_spec, load, quest: __export_quest, section: __export_section, presents: __export_presents, presentLabel: __export_presentLabel, missions: __export_missions, help: __export_help, check: __export_check, next: __export_next, pickLists: __export_pickLists, save: __export_save, exportTo: __export_exportTo };
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// Ported from LS Tools desktop: main/questspec.js (mechanical CJS -> ESM)
|
||||
// What the client and the game server know about quests (see docs/quests.md): enums, the quest
|
||||
// classes with the meaning of their custom values, present types and mission types. The Quests tab
|
||||
// uses it for labels, the editor's fields and the checks.
|
||||
|
||||
const OCCUR = {
|
||||
0: "Any event",
|
||||
1: "Enter lobby",
|
||||
2: "Enter PvE battle",
|
||||
3: "Enter PvP battle",
|
||||
4: "PvP final result",
|
||||
5: "Battle final result",
|
||||
6: "Enter plaza",
|
||||
7: "Fishing success",
|
||||
8: "Fishing failed",
|
||||
9: "Fishing level up",
|
||||
10: "Fishing sell",
|
||||
11: "Hero acquired",
|
||||
12: "Grade exp acquired",
|
||||
13: "Hero exp acquired",
|
||||
14: "Enter lobby / plaza",
|
||||
15: "Game login",
|
||||
16: "PvE round clear",
|
||||
17: "Enter PvE room",
|
||||
18: "Enter PvP room",
|
||||
19: "Excavation level up",
|
||||
20: "Etc item use",
|
||||
21: "Camp season reward",
|
||||
22: "Login after being away (days)",
|
||||
23: "PC room authority",
|
||||
24: "Friend recommend",
|
||||
25: "Login after being away (date)",
|
||||
26: "Practice success",
|
||||
};
|
||||
|
||||
const COMPLETE = {
|
||||
0: "None",
|
||||
1: "PvE round clear",
|
||||
2: "Enter PvP battle",
|
||||
3: "PvP KO",
|
||||
4: "PvP win",
|
||||
5: "Grade up",
|
||||
6: "Time growth try",
|
||||
7: "Time growth success",
|
||||
8: "Peso growth try",
|
||||
9: "Buy etc item",
|
||||
10: "Fishing try",
|
||||
11: "Fishing success",
|
||||
12: "Fishing failed",
|
||||
13: "Fishing level up",
|
||||
14: "Fishing sell peso",
|
||||
15: "Fishing success item",
|
||||
16: "PvP award row",
|
||||
17: "Hero practice success",
|
||||
18: "Buy gear",
|
||||
19: "Gear reinforce success",
|
||||
20: "Hero level up",
|
||||
21: "Enter plaza",
|
||||
22: "Manual close",
|
||||
23: "Game login",
|
||||
24: "PvE KO",
|
||||
25: "Pick item",
|
||||
26: "Tutorial clear",
|
||||
27: "Present received",
|
||||
28: "Camp join",
|
||||
29: "Enter camp battle",
|
||||
30: "Camp battle KO",
|
||||
31: "Camp battle win",
|
||||
32: "Camp season reward",
|
||||
33: "Award acquire",
|
||||
34: "Prisoner drop",
|
||||
35: "Prisoner save",
|
||||
36: "Enter headquarters",
|
||||
37: "Battle exp acquire",
|
||||
38: "PvP final result shown",
|
||||
39: "Drop kill",
|
||||
40: "Multi kill",
|
||||
41: "Enter lobby",
|
||||
42: "Enter battle (observer)",
|
||||
43: "Etc item use",
|
||||
44: "Friend request",
|
||||
45: "Friend made",
|
||||
46: "Stone attack",
|
||||
47: "Other king attack",
|
||||
48: "Crown hold time",
|
||||
49: "Boss mode: become boss",
|
||||
50: "Boss mode: kill as boss",
|
||||
51: "Hero acquired",
|
||||
52: "Attack hit",
|
||||
53: "Attack defense",
|
||||
54: "Camp exp acquire",
|
||||
55: "Camp final result shown",
|
||||
56: "Quick start option",
|
||||
57: "Soccer ball hit",
|
||||
58: "Soccer goal",
|
||||
59: "Soccer assist",
|
||||
60: "Soccer save",
|
||||
61: "Excavation try",
|
||||
62: "Excavation success",
|
||||
63: "PvE team KO",
|
||||
64: "Excavation fail",
|
||||
65: "Screenshot",
|
||||
66: "Movie mode",
|
||||
67: "Make movie",
|
||||
68: "Gear acquired",
|
||||
69: "Gear equipped",
|
||||
70: "KO while wearing gear",
|
||||
71: "Excavation level up",
|
||||
72: "Gangsi kill",
|
||||
73: "Gangsi human kill",
|
||||
74: "Gangsi alive time",
|
||||
75: "Gangsi human win",
|
||||
76: "Hit with attack attribute",
|
||||
77: "PC room login",
|
||||
78: "Champion AI clear",
|
||||
79: "Accessory compound success",
|
||||
80: "Practice success",
|
||||
};
|
||||
|
||||
const ROOM_STYLE = { "-1": "Any", 1: "Normal battle room", 2: "Ladder / camp team", 3: "Any battle", 4: "Shuffle (today's mode)" };
|
||||
|
||||
const MODE = {
|
||||
"-1": "Any",
|
||||
0: "None",
|
||||
1: "Symbol",
|
||||
2: "Catch",
|
||||
3: "King",
|
||||
4: "Training",
|
||||
5: "Survival",
|
||||
6: "Team survival",
|
||||
7: "Boss",
|
||||
8: "Monster survival",
|
||||
9: "Football",
|
||||
10: "Hero match",
|
||||
11: "Gangsi",
|
||||
12: "Dungeon A",
|
||||
13: "Headquarters",
|
||||
14: "Catch running man",
|
||||
15: "Fight club",
|
||||
16: "Tower defense",
|
||||
17: "Dark Xmas",
|
||||
18: "Fire temple",
|
||||
19: "Double crown",
|
||||
20: "Shuffle bonus",
|
||||
21: "Factory",
|
||||
22: "Team survival AI",
|
||||
23: "House",
|
||||
24: "My room",
|
||||
25: "Underwear",
|
||||
26: "CBT",
|
||||
27: "Raid",
|
||||
28: "Succession",
|
||||
29: "Practice",
|
||||
30: "Flag capture",
|
||||
31: "Arena",
|
||||
32: "Battle",
|
||||
33: "Farming",
|
||||
};
|
||||
|
||||
const CHANNELING = { "-1": "Any", 0: "Wemade", 300: "MGame", 400: "Daum", 600: "Naver", 700: "Tooniland", 800: "Nexon", 900: "Hangame", 1000: "Valofe", 1100: "HappyTalk" };
|
||||
|
||||
// custom value labels; "list" = a count N at that value followed by N codes
|
||||
const G = "Min grade";
|
||||
const C = (name, desc, qc, custom = [], opts = {}) => ({ name, desc, qc, custom, ...opts });
|
||||
const CLASSES = [
|
||||
C("QuestBasic", "Done on the first matching event", 41),
|
||||
C("QuestMonsterModeClear", "Clear a PvE floor N times", 1, [G, "Floor (== round)", "Clear count", "Max players at start"], { target: 3 }),
|
||||
C("QuestEnterBattlePvPMode", "Enter a PvP battle", 2, [G]),
|
||||
C("QuestEnterBattlePvPModeKO", "PvP kills", 3, ["Kill count", "Best attacker pass (1 = ignore)"], { target: 1 }),
|
||||
C("QuestEnterBattlePvPModeWin", "PvP wins", 4, ["Win count"], { target: 1 }),
|
||||
C("QuestPvEMonsterKill", "PvE monster kills", 63, ["Kill count", "Min floor", "Max floor", "Killer class (0 any)", G, "Monster codes N (0 any)"], { target: 1, list: 6 }),
|
||||
C("QuestGradeUP", "Reach a grade", 5, [G, "Target grade"], { target: 2 }),
|
||||
C("QuestTimeGrowthTry", "Try time growth", 6, [G]),
|
||||
C("QuestTimeGrowthSuccess", "Growth level reached", 7, ["Target growth level"], { target: 1 }),
|
||||
C("QuestPesoGrowthTry", "Try peso growth", 8, [G]),
|
||||
C("QuestFishingTry", "Fish once", 10),
|
||||
C("QuestFishingSuccess", "Successful catches", 11, ["Count", "Min fishing level"], { target: 1 }),
|
||||
C("QuestFishingFailed", "Failed catches", 12, ["Count", "Min fishing level"], { target: 1 }),
|
||||
C("QuestFishingLevelUP", "Reach a fishing level", 13, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
|
||||
C("QuestFishingSellPeso", "Fish sales of at least X peso", 14, ["Min fishing level", "Min peso per sale", "Count"], { target: 3 }),
|
||||
C("QuestFishingSuccessItem", "Catch an item type", 15, ["Min fishing level", "Item type", "Count"], { target: 3 }),
|
||||
C("QuestBattlePvPModeAwardAcquire", "Awards in one result", 16, [G, "Award count"], { target: 2 }),
|
||||
C("QuestSoldierPractice", "Practice with a hero", 17, ["Hero class"]),
|
||||
C("QuestExtraItemReinforceSuccess", "Reinforce gear to a level", 19, [G, "Target level", "Gear codes N (0 any)"], { target: 2, list: 3 }),
|
||||
C("QuestSoldierLevelUP", "Hero level", 20, ["Hero class", "Target level", "Min level to offer", "Gauge base"], { target: 2 }),
|
||||
C("QuestOpenManual", "Close a manual page", 22, [G, "Manual id (-1 any)"]),
|
||||
C("QuestLoginCount", "Log in on separate days", 23, [G, "Login count", "Min days between", "Max days (resets above)"], { target: 2 }),
|
||||
C("QuestEnterPlaza", "Enter the plaza", 21, [G]),
|
||||
C("QuestGetPotion", "Pick up potions", 25, ["Round", "Count", "Min floor", "Max floor"], { target: 2 }),
|
||||
C("QuestTutorial", "Tutorial users only", 26),
|
||||
C("QuestPresentReceive", "Receive gifts", 27, [G, "Count"], { target: 2 }),
|
||||
C("QuestCampJoin", "Join a camp", 28, [G]),
|
||||
C("QuestEnterCampBattle", "Enter a camp battle", 29, [G]),
|
||||
C("QuestCampBattleKO", "Camp battle kills", 30, [G, "Kills", "Best attacker pass"], { target: 2 }),
|
||||
C("QuestCampBattleWin", "Camp battle wins", 31, [G, "Wins"], { target: 2 }),
|
||||
C("QuestCampSeasonReward", "Camp season result", 32, [G, "1 win / 2 lose", "Min points (-1)", "Max rank (-1)", "Min bonus peso (-1)"]),
|
||||
C("QuestAwardAcquire", "Award points", 33, [G, "Award type (-1 any)", "Count"], { target: 3 }),
|
||||
C("QuestPrisonerDrop", "Drop prisoners", 34, [G, "Count"], { target: 2 }),
|
||||
C("QuestPrisonerSave", "Rescue prisoners", 35, [G, "Count"], { target: 2 }),
|
||||
C("QuestBattleLevel", "Party level", 38, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
|
||||
C("QuestAwardLevel", "Award level", 33, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
|
||||
C("QuestContribute", "Contribution in one match", 38, [G, "Min contribution", "Count"], { target: 3 }),
|
||||
C("QuestDropKill", "Drop kills", 39, [G, "Kills", "Best attacker pass"], { target: 2 }),
|
||||
C("QuestMultiKill", "Kills in one stage", 40, [G, "Kills", "Best attacker pass"], { target: 2 }),
|
||||
C("QuestPvPConsecution", "PvP win streak", 4, [G, "Streak"], { target: 2 }),
|
||||
C("QuestCampConsecution", "Camp win streak", 31, [G, "Streak"], { target: 2 }),
|
||||
C("QuestEtcItemUse", "Use etc items", 43, [G, "Use count", "Etc item codes N (0 never completes)"], { target: 2, list: 3 }),
|
||||
C("QuestEtcItemCnt", "Own N of an etc item", 43, [G, "Count", "Etc item code", "Consume on reward (1)"], { target: 2 }),
|
||||
C("QuestRequestFriend", "Send a friend request", 44, [G]),
|
||||
C("QuestMakeFriends", "Friend count", 45, [G, "Target", "Min friends to offer"], { target: 2 }),
|
||||
C("QuestModePlayTime", "Minutes played", 38, [G, "Minutes", "Hero class (-1 any)"], { target: 2 }),
|
||||
C("QuestStoneAttack", "Damage to the stone", 46, [G, "Total damage", "Team (0 own, 1 enemy)"], { target: 2 }),
|
||||
C("QuestKingAttack", "Hit the enemy king", 47, [G, "Count"], { target: 2 }),
|
||||
C("QuestCrownHoldTime", "Hold the crown", 48, [G, "Seconds"], { target: 2 }),
|
||||
C("QuestBossModeBecomeBoss", "Become the boss", 49, [G, "Count"], { target: 2 }),
|
||||
C("QuestBossModeBosswithKill", "Kills as the boss", 50, [G, "Kills", "0 = reset when not boss"], { target: 2 }),
|
||||
C("QuestMortmainSoldierCount", "Permanent heroes owned", 51, [G, "Target", "Min to offer"], { target: 2 }),
|
||||
C("QuestHitCount", "Hits landed", 52, [G, "Count"], { target: 2 }),
|
||||
C("QuestDefenceCount", "Blocks", 53, [G, "Count"], { target: 2 }),
|
||||
C("QuestPickItem", "Pick up items", 25, [G, "Count", "Item codes N (0 any)"], { target: 2, list: 3 }),
|
||||
C("QuestCampLevel", "Ladder level", 55, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
|
||||
C("QuestBuyItem", "Buy items", 9, [G, "Count", "Codes N (0 any; etc type for 9, machine for 18)"], { target: 2, list: 3 }),
|
||||
C("QuestMaxFriendSlot", "Friend slots", 9, [G, "Target"], { target: 2 }),
|
||||
C("QuestRealTimeCount", "Seconds connected", 1, ["Min grade", "Max grade", "Seconds"], { target: 3 }),
|
||||
C("QuestPlayTimeCount", "Seconds played", 1, ["Min grade", "Max grade", "Seconds"], { target: 3 }),
|
||||
C("QuestFriendRecommendPlayTime", "Seconds played (friend recommend)", 1, ["Min grade", "Max grade", "Seconds"], { target: 3 }),
|
||||
C("QuestQuickStartOption", "Pick a quick start type", 56, [G, "Type"]),
|
||||
C("QuestBuySoldier", "Acquire heroes", 51, [G, "Count", "Type (-1 any, 0 timed, 1 permanent)", "Style (-1 any, 0 bought, 1 trial, 2 PC room, 3 HQ)", "Hero classes N (0 any)"], { target: 2, list: 5 }),
|
||||
C("QuestPvETimeAttack", "Clear a floor in X seconds", 1, [G, "Floor", "Max seconds"]),
|
||||
C("QuestSoccerBallHit", "Soccer ball hits", 57, [G, "Count"], { target: 2 }),
|
||||
C("QuestSoccerGoal", "Soccer goals", 58, [G, "Count"], { target: 2 }),
|
||||
C("QuestSoccerAssist", "Soccer assists", 59, [G, "Count"], { target: 2 }),
|
||||
C("QuestSoccerSave", "Soccer saves", 60, [G, "Count"], { target: 2 }),
|
||||
C("QuestExcavationTry", "Dig once", 61, ["Min dig level"]),
|
||||
C("QuestExcavationLevelUP", "Dig level", 71, ["Min level to offer", "Target level", "Gauge base"], { target: 2 }),
|
||||
C("QuestExcavationSuccess", "Artifacts found", 62, ["Min dig level", "Count", "(unused)", "Min peso", "(unused)", "(unused)", "Artifact codes N (0 any)"], { target: 2, list: 7 }),
|
||||
C("QuestExcavationFail", "Failed digs", 64, ["Min dig level", "Count"], { target: 2 }),
|
||||
C("QuestExcavationTime", "Seconds digging", 10, ["Min dig level", "Seconds"], { target: 2 }),
|
||||
C("QuestScreenShot", "Screenshots", 65, [G, "Count"], { target: 2 }),
|
||||
C("QuestMovieMode", "Movie mode", 66, [G]),
|
||||
C("QuestMakeMovie", "Make a movie", 67, [G]),
|
||||
C("QuestExtraItemAcquire", "Get gear", 68, [G, "Period (-1 permanent, >0 at least, 0 any)", "Gear codes N (0 any)"], { list: 3 }),
|
||||
C("QuestExtraItemEquip", "Equip gear", 69, [G, "Period type (-1 any)", "Gear code (not compared by the client)"]),
|
||||
C("QuestExtraItemEquipKo", "Kills while wearing gear", 70, [G, "Kills", "Best attacker pass", "Gear code (0 never completes)"], { target: 2 }),
|
||||
C("QuestGangsiKill", "Gangsi kills", 72, [G, "Count"], { target: 2 }),
|
||||
C("QuestGangsiHumanKill", "Gangsi: human kills", 73, [G, "Count"], { target: 2 }),
|
||||
C("QuestGangsiAliveTime", "Gangsi: seconds alive", 74, [G, "Seconds"], { target: 2 }),
|
||||
C("QuestGangsiHumanWin", "Gangsi: human wins", 75, [G, "Count"], { target: 2 }),
|
||||
C("QuestHitAttackAttribute", "Hits with an attack attribute", 76, [G, "Count", "Codes N", "Code (only this one is compared)"], { target: 2 }),
|
||||
C("QuestGuildLevel", "Guild level", 32, ["Target level"], { target: 1 }),
|
||||
C("QuestGuildLevelMaintenance", "Keep a guild level", 32, ["Min guild level", "Seasons", "Min bonus peso"], { target: 2 }),
|
||||
C("QuestGuildTeamWin", "Guild team camp wins", 31, [G, "Wins"], { target: 2 }),
|
||||
C("QuestGuildTeamPlayTime", "Minutes in guild team battles", 38, [G, "Minutes"], { target: 2 }),
|
||||
C("QuestPlayTimeRepeat", "Rewards every X minutes (never completes)", 1, [G, "Minutes"]),
|
||||
C("QuestDormantUser", "Returning player (days away)", 0, [G, "Days"]),
|
||||
C("QuestDormantUserCustom", "Returning player (last logout before a date)", 0, [G, "Year", "Month", "Day"]),
|
||||
C("QuestDevKMove", "Developer K in the practice room", 0, [G, "X", "Y", "Z", "Range"]),
|
||||
C("QuestPracticeSuccess", "Practice success", 80, ["Min grade", "Practice number", "Min stars"]),
|
||||
];
|
||||
|
||||
const PRESENT_TYPES = {
|
||||
1: { name: "Hero", v1: "Hero class", v2: "Hours (0 = permanent)" },
|
||||
2: { name: "Decoration", v1: "class*100000 + sex*1000 + type", v2: "Code" },
|
||||
3: { name: "Etc item", v1: "Etc item type", v2: "Amount" },
|
||||
4: { name: "Peso", v1: "Amount", v2: "" },
|
||||
5: { name: "Gear", v1: "Gear code", v2: "ext*100000000 + reinforce*10000 + hours" },
|
||||
6: { name: "Gear box", v1: "Machine code", v2: "(peso array+1)*100 + gold" },
|
||||
7: { name: "Random decoration", v1: "Value 1", v2: "Value 2" },
|
||||
8: { name: "Grade exp", v1: "Exp", v2: "" },
|
||||
9: { name: "Medal", v1: "Medal code", v2: "Hours" },
|
||||
10: { name: "Alchemic", v1: "Code", v2: "Amount" },
|
||||
11: { name: "Pet", v1: "Code", v2: "Value" },
|
||||
12: { name: "Costume", v1: "Costume code", v2: "Period (value % 10000)" },
|
||||
13: { name: "Bonus cash", v1: "Amount", v2: "" },
|
||||
14: { name: "Accessory", v1: "Code", v2: "Value" },
|
||||
15: { name: "Costume box", v1: "Machine code", v2: "Value" },
|
||||
16: { name: "Spirit", v1: "Code", v2: "Value" },
|
||||
};
|
||||
|
||||
const MISSION_TYPES = {
|
||||
1: { name: "Login time", values: ["Minutes online"] },
|
||||
2: { name: "Fishing", values: ["Count", "0 any / 1 success / 2 fail"] },
|
||||
3: { name: "Peso", values: ["Amount", "1 spent / 2 earned / 0 any"] },
|
||||
4: { name: "Monster kill", values: ["Count", "Monster code"] },
|
||||
5: { name: "Item buy", values: ["Present type", "Code", "Count"] },
|
||||
6: { name: "Clover", values: ["Count", "1 send / 2 receive / 0 any"] },
|
||||
7: { name: "Excavation", values: ["Count", "0 any / 1 success / 2 fail"] },
|
||||
8: { name: "Item use", values: ["Present type", "Code", "Count"] },
|
||||
9: { name: "Reinforce", values: ["Count", "1 success / 2 fail / 0 any"] },
|
||||
10: { name: "Mode play", values: ["Plays", "Min play time (ms)", "Room style (0 any)", "Mode (0 any)"] },
|
||||
11: { name: "Daily missions completed", values: ["Count"] },
|
||||
12: { name: "Daily all clear", values: ["Count"] },
|
||||
13: { name: "Weekly missions completed", values: ["Count"] },
|
||||
14: { name: "Weekly all clear", values: ["Count"] },
|
||||
15: { name: "Monthly all clear", values: ["Count"] },
|
||||
16: { name: "Additive use", values: ["Count", "Use type"] },
|
||||
};
|
||||
|
||||
// the sub-quest fields the editor shows, in order: [key, label, kind, default]
|
||||
// kind: text (STR string), int, bool, enum:<name>, date
|
||||
const SUB_FIELDS = [
|
||||
["title", "Title", "text", ""],
|
||||
["progress", "Progress text", "text", ""],
|
||||
["progress_result", "Counter suffix", "text", ""],
|
||||
["icon", "Icon", "icon", ""],
|
||||
["sub_icon", "Small icon", "icon", ""],
|
||||
["help", "Help index", "int", 0],
|
||||
["perform_type", "Type", "enum:perform", 1],
|
||||
["channeling_type", "Channeling", "enum:channeling", -1],
|
||||
["occur_value", "Offered on", "enum:occur", 1],
|
||||
["occur_value_ex", "Offered: etc item type", "int", -1],
|
||||
["occur_mode", "Offered in mode", "enum:mode", -1],
|
||||
["occur_excepting_mode", "Not offered in mode", "enum:mode", -1],
|
||||
["occur_room_style", "Offered in room", "enum:room", -1],
|
||||
["occur_limit_grade_min", "Offered from grade", "int", -1],
|
||||
["occur_limit_grade_max", "Offered up to grade", "int", -1],
|
||||
["complete_value", "Completes on", "enum:complete", 0],
|
||||
["complete_mode", "Completes in mode", "enum:mode", -1],
|
||||
["complete_excepting_mode", "Not completed in mode", "enum:mode", -1],
|
||||
["complete_room_style", "Completes in room", "enum:room", -1],
|
||||
["oneday_style", "Daily (reset every day)", "bool", 0],
|
||||
["repeat_style", "Repeatable", "bool", 0],
|
||||
["guild_style", "Needs a guild", "bool", 0],
|
||||
["guild_terms_level", "Min guild level", "int", 0],
|
||||
["pcroom_style", "PC room only", "bool", 0],
|
||||
["prev_main_index", "Needs quest (main)", "int", 0],
|
||||
["prev_sub_index", "Needs quest (sub)", "int", 0],
|
||||
["next_main_index", "Next quest (main)", "int", 0],
|
||||
["next_sub_index", "Next quest (sub)", "int", 0],
|
||||
["rewardselect_style", "Player picks rewards", "bool", 0],
|
||||
["rewardselect_num", "Rewards to pick", "int", 1],
|
||||
["invited_pass", "Auto-pass invites", "bool", 0],
|
||||
["user_mode_pass", "Not in custom rooms", "bool", 0],
|
||||
["death_time_pass", "Not while dead", "bool", 0],
|
||||
["play_occur_show", "Popup: offered (play)", "bool", 1],
|
||||
["play_complete_show", "Popup: completed (play)", "bool", 1],
|
||||
["round_end_occur_show", "Popup: offered (round end)", "bool", 1],
|
||||
["round_end_complete_show", "Popup: completed (round end)", "bool", 1],
|
||||
["complete_game_alarm", "Announce completion", "bool", 0],
|
||||
["complete_web_alarm", "Web board on completion", "bool", 0],
|
||||
["costume", "Costume mark", "bool", 0],
|
||||
];
|
||||
|
||||
const ENUMS = {
|
||||
perform: { 1: "Normal", 2: "Event (dates)" },
|
||||
channeling: CHANNELING,
|
||||
occur: OCCUR,
|
||||
complete: COMPLETE,
|
||||
mode: MODE,
|
||||
room: ROOM_STYLE,
|
||||
};
|
||||
|
||||
export { CLASSES, ENUMS, PRESENT_TYPES, MISSION_TYPES, SUB_FIELDS, OCCUR, COMPLETE, MODE, ROOM_STYLE, CHANNELING };
|
||||
export default { CLASSES, ENUMS, PRESENT_TYPES, MISSION_TYPES, SUB_FIELDS, OCCUR, COMPLETE, MODE, ROOM_STYLE, CHANNELING };
|
||||
@@ -0,0 +1,19 @@
|
||||
// Web replacement of the desktop store.js: only the key-profile part the ported modules use.
|
||||
// (Workspaces, recent files and UI settings live in the UI thread's localStorage.)
|
||||
import { allKeys } from "../../keys.js";
|
||||
|
||||
/** Key profiles (full objects, like the desktop store) in the order they are tried. */
|
||||
export function keyOrder() {
|
||||
return allKeys();
|
||||
}
|
||||
|
||||
/** A key profile by id: { id, name, passwords: [type0, type1] }. */
|
||||
export function profile(id) {
|
||||
return allKeys().find((k) => k.id === id) || null;
|
||||
}
|
||||
|
||||
export function profiles() {
|
||||
return allKeys();
|
||||
}
|
||||
|
||||
export default { keyOrder, profile, profiles };
|
||||
@@ -0,0 +1,191 @@
|
||||
// Ported from LS Tools desktop: main/xlsx.js (mechanical CJS -> ESM)
|
||||
import zlib from "node:zlib";
|
||||
|
||||
// Minimal .xlsx reading / writing (no dependency): a zip of XML parts. Reading takes the cell text
|
||||
// of every sheet (shared and inline strings, numbers, booleans); writing makes one workbook with
|
||||
// plain text / number cells and a bold first row, which Excel, LibreOffice and Google Sheets open.
|
||||
|
||||
// ---------- zip ----------
|
||||
function readZip(buf) {
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error("Not a zip / xlsx file.");
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
const files = new Map();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("Bad zip directory.");
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const csize = buf.readUInt32LE(p + 20);
|
||||
const nlen = buf.readUInt16LE(p + 28);
|
||||
const xlen = buf.readUInt16LE(p + 30);
|
||||
const clen = buf.readUInt16LE(p + 32);
|
||||
const local = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString("utf8", p + 46, p + 46 + nlen);
|
||||
const data = buf.subarray(local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28));
|
||||
const raw = data.subarray(0, csize);
|
||||
files.set(name, method === 0 ? Buffer.from(raw) : zlib.inflateRawSync(raw));
|
||||
p += 46 + nlen + xlen + clen;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function writeZip(files) {
|
||||
const parts = [];
|
||||
const dir = [];
|
||||
let off = 0;
|
||||
for (const [name, content] of files) {
|
||||
const data = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
|
||||
const comp = zlib.deflateRawSync(data);
|
||||
const nameBuf = Buffer.from(name, "utf8");
|
||||
const crc = crc32(data);
|
||||
const head = Buffer.alloc(30);
|
||||
head.writeUInt32LE(0x04034b50, 0);
|
||||
head.writeUInt16LE(20, 4);
|
||||
head.writeUInt16LE(0x0800, 6);
|
||||
head.writeUInt16LE(8, 8);
|
||||
head.writeUInt32LE(crc, 14);
|
||||
head.writeUInt32LE(comp.length, 18);
|
||||
head.writeUInt32LE(data.length, 22);
|
||||
head.writeUInt16LE(nameBuf.length, 26);
|
||||
parts.push(head, nameBuf, comp);
|
||||
const c = Buffer.alloc(46);
|
||||
c.writeUInt32LE(0x02014b50, 0);
|
||||
c.writeUInt16LE(20, 4);
|
||||
c.writeUInt16LE(20, 6);
|
||||
c.writeUInt16LE(0x0800, 8);
|
||||
c.writeUInt16LE(8, 10);
|
||||
c.writeUInt32LE(crc, 16);
|
||||
c.writeUInt32LE(comp.length, 20);
|
||||
c.writeUInt32LE(data.length, 24);
|
||||
c.writeUInt16LE(nameBuf.length, 28);
|
||||
c.writeUInt32LE(off, 42);
|
||||
dir.push(c, nameBuf);
|
||||
off += 30 + nameBuf.length + comp.length;
|
||||
}
|
||||
const dirBuf = Buffer.concat(dir);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(files.size ?? files.length, 8);
|
||||
end.writeUInt16LE(files.size ?? files.length, 10);
|
||||
end.writeUInt32LE(dirBuf.length, 12);
|
||||
end.writeUInt32LE(off, 16);
|
||||
return Buffer.concat([...parts, dirBuf, end]);
|
||||
}
|
||||
|
||||
// ---------- xml helpers ----------
|
||||
const unxml = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&");
|
||||
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
||||
const colIndex = (ref) => {
|
||||
const m = /^([A-Z]+)/.exec(ref);
|
||||
let n = 0;
|
||||
for (const ch of m ? m[1] : "A") n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n - 1;
|
||||
};
|
||||
const colName = (i) => {
|
||||
let s = "";
|
||||
for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** → [{ name, rows: [[text, …], …] }] */
|
||||
function readXlsx(buf) {
|
||||
const files = readZip(buf);
|
||||
const text = (n) => files.get(n)?.toString("utf8") || "";
|
||||
const shared = [...text("xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => unxml([...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => t[1]).join("")));
|
||||
const rels = new Map([...text("xl/_rels/workbook.xml.rels").matchAll(/<Relationship\b[^>]*>/g)].map((m) => [/Id="([^"]+)"/.exec(m[0])?.[1], /Target="([^"]+)"/.exec(m[0])?.[1]]));
|
||||
const sheets = [];
|
||||
for (const m of text("xl/workbook.xml").matchAll(/<sheet\b[^>]*>/g)) {
|
||||
const name = unxml(/name="([^"]*)"/.exec(m[0])?.[1] || "");
|
||||
const rid = /r:id="([^"]+)"/.exec(m[0])?.[1];
|
||||
let target = rels.get(rid) || "";
|
||||
target = target.startsWith("/") ? target.slice(1) : `xl/${target}`;
|
||||
const rows = [];
|
||||
for (const r of text(target).matchAll(/<row\b([^>]*)>([\s\S]*?)<\/row>|<row\b([^>]*)\/>/g)) {
|
||||
const rn = Number(/r="(\d+)"/.exec(r[1] || r[3] || "")?.[1] || rows.length + 1) - 1;
|
||||
const cells = [];
|
||||
for (const c of (r[2] || "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
||||
const attrs = c[1];
|
||||
const ref = /r="([A-Z]+\d+)"/.exec(attrs)?.[1];
|
||||
const t = /t="([^"]+)"/.exec(attrs)?.[1];
|
||||
const body = c[2] || "";
|
||||
const v = /<v>([\s\S]*?)<\/v>/.exec(body)?.[1];
|
||||
let val = "";
|
||||
if (t === "s") val = shared[Number(v)] ?? "";
|
||||
else if (t === "inlineStr") val = unxml([...body.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => x[1]).join(""));
|
||||
else if (t === "b") val = v === "1" ? "TRUE" : "FALSE";
|
||||
else if (v !== undefined) val = unxml(v);
|
||||
cells[ref ? colIndex(ref) : cells.length] = val;
|
||||
}
|
||||
rows[rn] = Array.from(cells, (x) => x ?? "");
|
||||
}
|
||||
sheets.push({ name, rows: Array.from(rows, (x) => x || []) });
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
|
||||
/** sheets: [{ name, rows: [[value]] }] (numbers stay numbers) → .xlsx bytes */
|
||||
function writeXlsx(sheets) {
|
||||
const files = new Map();
|
||||
files.set(
|
||||
"[Content_Types].xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`,
|
||||
);
|
||||
files.set("_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`);
|
||||
files.set(
|
||||
"xl/workbook.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${sheets.map((s, i) => `<sheet name="${xml(s.name.replace(/[\\/?*[\]:]/g, "_").slice(0, 31) || `Sheet${i + 1}`)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets></workbook>`,
|
||||
);
|
||||
files.set(
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}<Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`,
|
||||
);
|
||||
files.set(
|
||||
"xl/styles.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="2"><xf fontId="0"/><xf fontId="1" applyFont="1"/></cellXfs></styleSheet>`,
|
||||
);
|
||||
sheets.forEach((s, i) => {
|
||||
const rows = s.rows
|
||||
.map((row, r) => {
|
||||
const cells = row
|
||||
.map((v, c) => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
const ref = `${colName(c)}${r + 1}`;
|
||||
const st = r === 0 ? ' s="1"' : "";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return `<c r="${ref}"${st}><v>${v}</v></c>`;
|
||||
return `<c r="${ref}"${st} t="inlineStr"><is><t xml:space="preserve">${xml(v)}</t></is></c>`;
|
||||
})
|
||||
.join("");
|
||||
return `<row r="${r + 1}">${cells}</row>`;
|
||||
})
|
||||
.join("");
|
||||
files.set(
|
||||
`xl/worksheets/sheet${i + 1}.xml`,
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetData>${rows}</sheetData></worksheet>`,
|
||||
);
|
||||
});
|
||||
return writeZip(files);
|
||||
}
|
||||
|
||||
export { readZip, writeZip, readXlsx, writeXlsx };
|
||||
export default { readZip, writeZip, readXlsx, writeXlsx };
|
||||
@@ -0,0 +1,5 @@
|
||||
// Must be the first import of the worker: the ported modules expect Node's Buffer as a global.
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
globalThis.Buffer = Buffer;
|
||||
globalThis.global = globalThis;
|
||||
@@ -0,0 +1,12 @@
|
||||
// "node:child_process": a browser cannot start programs. Features that run an external exe
|
||||
// are left out of the web version; this only makes a clear error.
|
||||
function unavailable() {
|
||||
throw new Error("Running external programs is not possible in the browser (use the desktop LS Tools for this).");
|
||||
}
|
||||
export const spawn = unavailable;
|
||||
export const spawnSync = unavailable;
|
||||
export const execFile = unavailable;
|
||||
export const execFileSync = unavailable;
|
||||
export const exec = unavailable;
|
||||
export const execSync = unavailable;
|
||||
export default { spawn, spawnSync, execFile, execFileSync, exec, execSync };
|
||||
@@ -0,0 +1,12 @@
|
||||
// node:crypto subset used by the desktop modules.
|
||||
export function randomBytes(n) {
|
||||
const b = Buffer.alloc(n);
|
||||
crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
export function randomUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export default { randomBytes, randomUUID };
|
||||
@@ -0,0 +1,21 @@
|
||||
// "electron" for the ported main-process modules. Only app.getPath is used outside the dialogs
|
||||
// (which the browser answers in src/legacy/bridge.js). userData is a virtual folder kept in the
|
||||
// browser's IndexedDB (see vfs.takePersisted / restore).
|
||||
const PATHS = { userData: "/userdata", temp: "/tmp", downloads: "/out", documents: "/out", desktop: "/out", home: "/out" };
|
||||
|
||||
export const app = {
|
||||
getPath: (name) => PATHS[name] || `/userdata/${name}`,
|
||||
getVersion: () => "web",
|
||||
isPackaged: true,
|
||||
};
|
||||
|
||||
const unavailable = (what) => () => {
|
||||
throw new Error(`${what} is not available in the browser`);
|
||||
};
|
||||
|
||||
export const dialog = { showOpenDialog: unavailable("dialog"), showSaveDialog: unavailable("dialog"), showMessageBox: unavailable("dialog") };
|
||||
export const shell = { openPath: async () => "", showItemInFolder: () => {}, openExternal: async () => {} };
|
||||
export const ipcMain = { handle: () => {}, on: () => {} };
|
||||
export const BrowserWindow = class {};
|
||||
|
||||
export default { app, dialog, shell, ipcMain, BrowserWindow };
|
||||
@@ -0,0 +1,9 @@
|
||||
// node:fs/promises subset. Only Pack.optimize() uses it, which belongs to the removed
|
||||
// IOP Manager; kept so the module loads, and fails clearly if anything calls it.
|
||||
export async function open() {
|
||||
const e = new Error("Writing new files is not available in the web version");
|
||||
e.code = "EROFS";
|
||||
throw e;
|
||||
}
|
||||
|
||||
export default { open };
|
||||
@@ -0,0 +1,7 @@
|
||||
// "node:os" subset for the ported modules.
|
||||
export const tmpdir = () => "/tmp";
|
||||
export const homedir = () => "/out";
|
||||
export const platform = () => "win32";
|
||||
export const EOL = "\r\n";
|
||||
export const cpus = () => [{}];
|
||||
export default { tmpdir, homedir, platform, EOL, cpus };
|
||||
@@ -0,0 +1,29 @@
|
||||
// node:zlib subset used by the desktop modules, on fflate.
|
||||
import { inflateSync, deflateSync } from "fflate";
|
||||
|
||||
const TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
export function crc32(buf, prev = 0) {
|
||||
let c = ~prev >>> 0;
|
||||
for (let i = 0; i < buf.length; i++) c = TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return ~c >>> 0;
|
||||
}
|
||||
|
||||
export function inflateRawSync(buf) {
|
||||
return Buffer.from(inflateSync(buf instanceof Uint8Array ? buf : new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
export function deflateRawSync(buf, opts = {}) {
|
||||
const level = opts.level === undefined ? 6 : opts.level;
|
||||
return Buffer.from(deflateSync(buf instanceof Uint8Array ? buf : new Uint8Array(buf), { level }));
|
||||
}
|
||||
|
||||
export default { crc32, inflateRawSync, deflateRawSync };
|
||||
@@ -0,0 +1,419 @@
|
||||
// Synchronous file system for the worker, on top of File System Access handles.
|
||||
//
|
||||
// The desktop modules read files synchronously (fs.readSync at an offset). In a worker,
|
||||
// FileReaderSync reads a Blob slice synchronously, so the same contract holds here.
|
||||
// Handles are resolved lazily: when sync code touches a folder or file whose handle/File is not
|
||||
// resolved yet, a NeedResolve error is thrown; the worker's dispatcher resolves it (async) and
|
||||
// runs the call again. Calls are therefore expected to be repeatable up to their first write.
|
||||
//
|
||||
// Paths are POSIX-style virtual paths: /<mount>/<relative path>. Names compare case-insensitively
|
||||
// (Windows game folders).
|
||||
|
||||
const reader = new FileReaderSync();
|
||||
const mounts = new Map(); // mount name -> { handle }
|
||||
const dirs = new Map(); // lower path -> Map(lower name -> { name, kind, handle })
|
||||
const files = new Map(); // lower path -> File
|
||||
const overlay = new Map(); // lower path -> { name, data: Uint8Array } written in this session (memory only)
|
||||
const fds = new Map(); // fd -> { path, file, pos }
|
||||
let nextFd = 3;
|
||||
|
||||
export class NeedResolve extends Error {
|
||||
constructor(kind, path) {
|
||||
super(`resolve ${kind} ${path}`);
|
||||
this.kind = kind;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (p) => {
|
||||
const parts = [];
|
||||
for (const s of String(p).replace(/\\/g, "/").split("/")) {
|
||||
if (!s || s === ".") continue;
|
||||
if (s === "..") parts.pop();
|
||||
else parts.push(s);
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
const low = (p) => norm(p).toLowerCase();
|
||||
const parentOf = (p) => norm(p).replace(/\/[^/]*$/, "") || "/";
|
||||
const baseOf = (p) => norm(p).split("/").pop();
|
||||
|
||||
function enoent(p) {
|
||||
const e = new Error(`ENOENT: no such file or directory, '${p}'`);
|
||||
e.code = "ENOENT";
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Make a directory handle available as /<name>. */
|
||||
export function mount(name, handle) {
|
||||
mounts.set(name.toLowerCase(), { name, handle });
|
||||
dirs.delete("/" + name.toLowerCase());
|
||||
for (const k of [...dirs.keys()]) if (k.startsWith("/" + name.toLowerCase() + "/")) dirs.delete(k);
|
||||
for (const k of [...files.keys()]) if (k.startsWith("/" + name.toLowerCase() + "/")) files.delete(k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a list of Files available as /<name> (read-only): the <input webkitdirectory> fallback for
|
||||
* browsers without folder handles. `items` = [{ path: "sub/dir/file.ext", file: File }].
|
||||
*/
|
||||
export function mountFiles(name, items) {
|
||||
mount(name, null);
|
||||
const root = "/" + name.toLowerCase();
|
||||
const ensureDir = (key, display) => {
|
||||
if (!dirs.has(key)) dirs.set(key, new Map());
|
||||
return dirs.get(key);
|
||||
};
|
||||
ensureDir(root);
|
||||
for (const { path: rel, file } of items) {
|
||||
const parts = rel.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const listing = ensureDir(cur);
|
||||
const isFile = i === parts.length - 1;
|
||||
const childKey = cur + "/" + parts[i].toLowerCase();
|
||||
if (!listing.has(parts[i].toLowerCase())) listing.set(parts[i].toLowerCase(), { name: parts[i], kind: isFile ? "file" : "directory", handle: null });
|
||||
if (isFile) files.set(childKey, file);
|
||||
else ensureDir(childKey);
|
||||
cur = childKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unmount(name) {
|
||||
mounts.delete(name.toLowerCase());
|
||||
}
|
||||
|
||||
export function mountNames() {
|
||||
return [...mounts.values()].map((m) => m.name);
|
||||
}
|
||||
|
||||
/** Async part of the lazy resolution: list a folder or open a file. */
|
||||
export async function resolve(err) {
|
||||
if (err.kind === "dir") return resolveDir(err.path);
|
||||
if (err.kind === "file") return resolveFile(err.path);
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function dirHandleOf(p) {
|
||||
const parts = norm(p).split("/").filter(Boolean);
|
||||
const m = mounts.get((parts.shift() || "").toLowerCase());
|
||||
if (!m) return null;
|
||||
let h = m.handle;
|
||||
let cur = "/" + m.name.toLowerCase();
|
||||
for (const part of parts) {
|
||||
const listing = dirs.get(cur) || (await resolveDir(cur));
|
||||
const ent = listing.get(part.toLowerCase());
|
||||
if (!ent || ent.kind !== "directory") return null;
|
||||
h = ent.handle;
|
||||
cur += "/" + part.toLowerCase();
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async function resolveDir(p) {
|
||||
const key = low(p);
|
||||
if (dirs.has(key)) return dirs.get(key);
|
||||
const h = await dirHandleOf(p);
|
||||
const listing = new Map();
|
||||
if (h) for await (const [name, child] of h.entries()) listing.set(name.toLowerCase(), { name, kind: child.kind, handle: child });
|
||||
dirs.set(key, listing);
|
||||
return listing;
|
||||
}
|
||||
|
||||
async function resolveFile(p) {
|
||||
const key = low(p);
|
||||
if (files.has(key)) return files.get(key);
|
||||
const listing = await resolveDir(parentOf(p));
|
||||
const ent = listing.get(baseOf(p).toLowerCase());
|
||||
if (!ent || ent.kind !== "file") {
|
||||
files.set(key, null);
|
||||
return null;
|
||||
}
|
||||
const f = await ent.handle.getFile();
|
||||
files.set(key, f);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Drop cached Files so the next read sees changes made outside (another program, a save). */
|
||||
export function invalidate(p) {
|
||||
if (p) files.delete(low(p));
|
||||
else files.clear();
|
||||
}
|
||||
|
||||
function listingSync(p) {
|
||||
const key = low(p);
|
||||
if (key === "/") return new Map([...mounts.values()].map((m) => [m.name.toLowerCase(), { name: m.name, kind: "directory" }]));
|
||||
if (!mounts.has(key.split("/")[1])) return null;
|
||||
if (!dirs.has(key)) throw new NeedResolve("dir", p);
|
||||
return dirs.get(key);
|
||||
}
|
||||
|
||||
function fileSync(p) {
|
||||
const key = low(p);
|
||||
if (overlay.has(key)) return null;
|
||||
if (!files.has(key)) {
|
||||
const parent = listingSync(parentOf(p));
|
||||
if (!parent) throw enoent(p);
|
||||
const ent = parent.get(baseOf(p).toLowerCase());
|
||||
if (!ent || ent.kind !== "file") throw enoent(p);
|
||||
throw new NeedResolve("file", p);
|
||||
}
|
||||
const f = files.get(key);
|
||||
if (!f) throw enoent(p);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Folders that only exist because files were written under them (e.g. /userdata/presets). */
|
||||
function overlayDir(key) {
|
||||
const prefix = key === "/" ? "/" : key + "/";
|
||||
for (const k of overlay.keys()) if (k.startsWith(prefix)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function kindSync(p) {
|
||||
const key = low(p);
|
||||
if (overlay.has(key)) return "file";
|
||||
if (!mounts.has(key.split("/")[1]) && overlayDir(key)) return "directory";
|
||||
if (key === "/" || (mounts.has(key.slice(1)) && !key.slice(1).includes("/"))) return "directory";
|
||||
const parent = listingSync(parentOf(p));
|
||||
if (!parent) return null;
|
||||
return parent.get(baseOf(p).toLowerCase())?.kind || null;
|
||||
}
|
||||
|
||||
const toBuf = (ab) => Buffer.from(ab);
|
||||
|
||||
/**
|
||||
* Dev only: a "file" read piecewise over HTTP Range with synchronous XHR (allowed in workers), so
|
||||
* test packs of a gigabyte don't have to be copied into memory first.
|
||||
*/
|
||||
export function remoteFile(url, size, name) {
|
||||
return { remote: true, url, size, name, lastModified: 0 };
|
||||
}
|
||||
|
||||
const BLOCK = 1 << 20; // remote reads go in aligned 1 MB blocks, the last 96 kept
|
||||
const blocks = new Map(); // url#index -> Uint8Array
|
||||
|
||||
function remoteBlock(file, i) {
|
||||
const key = `${file.url}#${i}`;
|
||||
let b = blocks.get(key);
|
||||
if (b) {
|
||||
blocks.delete(key);
|
||||
blocks.set(key, b);
|
||||
return b;
|
||||
}
|
||||
const start = i * BLOCK;
|
||||
const end = Math.min(file.size, start + BLOCK);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", file.url, false);
|
||||
xhr.responseType = "arraybuffer";
|
||||
xhr.setRequestHeader("Range", `bytes=${start}-${end - 1}`);
|
||||
xhr.send();
|
||||
if (xhr.status !== 206 && xhr.status !== 200) throw new Error(`read ${file.name}: HTTP ${xhr.status}`);
|
||||
b = new Uint8Array(xhr.response);
|
||||
blocks.set(key, b);
|
||||
if (blocks.size > 96) blocks.delete(blocks.keys().next().value);
|
||||
return b;
|
||||
}
|
||||
|
||||
function readRange(file, start, end) {
|
||||
if (!file.remote) return reader.readAsArrayBuffer(start === 0 && end === file.size ? file : file.slice(start, end));
|
||||
end = Math.min(end, file.size);
|
||||
if (end <= start) return new ArrayBuffer(0);
|
||||
const out = new Uint8Array(end - start);
|
||||
for (let pos = start; pos < end; ) {
|
||||
const i = Math.floor(pos / BLOCK);
|
||||
const b = remoteBlock(file, i);
|
||||
const from = pos - i * BLOCK;
|
||||
const n = Math.min(b.length - from, end - pos);
|
||||
out.set(b.subarray(from, from + n), pos - start);
|
||||
pos += n;
|
||||
}
|
||||
return out.buffer;
|
||||
}
|
||||
|
||||
// ---------- node:fs subset ----------
|
||||
export function existsSync(p) {
|
||||
try {
|
||||
return kindSync(p) !== null;
|
||||
} catch (e) {
|
||||
if (e instanceof NeedResolve) throw e;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function statSync(p) {
|
||||
const kind = kindSync(p);
|
||||
if (!kind) throw enoent(p);
|
||||
if (kind === "directory") return { isDirectory: () => true, isFile: () => false, size: 0, mtimeMs: 0, mtime: new Date(0) };
|
||||
const ov = overlay.get(low(p));
|
||||
if (ov) return { isDirectory: () => false, isFile: () => true, size: ov.data.length, mtimeMs: Date.now(), mtime: new Date() };
|
||||
const f = fileSync(p);
|
||||
return { isDirectory: () => false, isFile: () => true, size: f.size, mtimeMs: f.lastModified, mtime: new Date(f.lastModified) };
|
||||
}
|
||||
|
||||
export function readdirSync(p, opts) {
|
||||
const listing = listingSync(p) || (overlayDir(low(p)) ? new Map() : null);
|
||||
if (!listing) throw enoent(p);
|
||||
const names = new Map([...listing.values()].map((e) => [e.name.toLowerCase(), e]));
|
||||
const prefix = low(p) + "/";
|
||||
for (const [k, v] of overlay) {
|
||||
if (!k.startsWith(prefix)) continue;
|
||||
const rest = k.slice(prefix.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash < 0) names.set(rest, { name: v.name, kind: "file" });
|
||||
else if (!names.has(rest.slice(0, slash))) {
|
||||
const shown = (v.display || "").slice(prefix.length).split("/")[0];
|
||||
names.set(rest.slice(0, slash), { name: shown || rest.slice(0, slash), kind: "directory" });
|
||||
}
|
||||
}
|
||||
const out = [...names.values()];
|
||||
if (opts && opts.withFileTypes) return out.map((e) => ({ name: e.name, isDirectory: () => e.kind === "directory", isFile: () => e.kind === "file" }));
|
||||
return out.map((e) => e.name);
|
||||
}
|
||||
|
||||
export function readFileSync(p, enc) {
|
||||
const ov = overlay.get(low(p));
|
||||
let data;
|
||||
if (ov) data = Buffer.from(ov.data);
|
||||
else {
|
||||
const f = fileSync(p);
|
||||
data = toBuf(readRange(f, 0, f.size));
|
||||
}
|
||||
if (typeof enc === "string") return data.toString(enc);
|
||||
if (enc && enc.encoding) return data.toString(enc.encoding);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Paths written during the current worker call; the UI thread offers them as downloads.
|
||||
// /userdata (settings, presets: electron app.getPath("userData")) is not downloaded but kept in
|
||||
// the browser (IndexedDB) through the persisted log instead.
|
||||
const written = new Map(); // lower path -> display path
|
||||
const persisted = new Map(); // lower path -> display path (written or deleted under /userdata)
|
||||
const isUserData = (key) => key.startsWith("/userdata/");
|
||||
|
||||
export function takeWritten() {
|
||||
const out = [...written.values()].map((p) => ({ path: p, name: baseOf(p), data: overlay.get(low(p))?.data }));
|
||||
written.clear();
|
||||
return out.filter((w) => w.data);
|
||||
}
|
||||
|
||||
/** Files changed under /userdata since the last call: data = null means deleted. */
|
||||
export function takePersisted() {
|
||||
const out = [...persisted.values()].map((p) => ({ path: p, data: overlay.get(low(p))?.data || null }));
|
||||
persisted.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Restores /userdata files saved by an earlier visit (no write log). */
|
||||
export function restore(list) {
|
||||
for (const { path: p, data } of list || []) overlay.set(low(p), { name: baseOf(p), display: norm(p), data: new Uint8Array(data) });
|
||||
}
|
||||
|
||||
/** Writes stay in memory for this session; phase 9 adds writing back to disk. */
|
||||
export function writeFileSync(p, data, enc) {
|
||||
const encoding = typeof enc === "string" ? enc : enc?.encoding || "utf8";
|
||||
const buf = typeof data === "string" ? Buffer.from(data, encoding) : ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : Buffer.from(data);
|
||||
const key = low(p);
|
||||
overlay.set(key, { name: baseOf(p), display: norm(p), data: new Uint8Array(buf) });
|
||||
if (isUserData(key)) persisted.set(key, norm(p));
|
||||
else written.set(key, norm(p));
|
||||
}
|
||||
|
||||
export function appendFileSync(p, data) {
|
||||
const old = overlay.get(low(p))?.data || new Uint8Array(0);
|
||||
const add = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
||||
writeFileSync(p, Buffer.concat([Buffer.from(old), add]));
|
||||
}
|
||||
|
||||
let tmpSeq = 0;
|
||||
/** fs.mkdtempSync(prefix): a fresh virtual folder; files written into it come back as downloads. */
|
||||
export function mkdtempSync(prefix) {
|
||||
return norm(`${prefix}${Date.now().toString(36)}${++tmpSeq}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The File behind a mounted path (or the dev remote descriptor { remote, url, size }), so callers
|
||||
* can build Blobs from slices of it without reading it (IOP Manager "Download pack").
|
||||
*/
|
||||
export function fileOf(p) {
|
||||
return fileSync(p);
|
||||
}
|
||||
|
||||
export function overlayFile(p) {
|
||||
return overlay.get(low(p)) || null;
|
||||
}
|
||||
|
||||
export function mkdirSync() {}
|
||||
export function rmSync(p) {
|
||||
const key = low(p);
|
||||
for (const k of [...overlay.keys()]) {
|
||||
if (k !== key && !k.startsWith(key + "/")) continue;
|
||||
const display = overlay.get(k).display || k;
|
||||
overlay.delete(k);
|
||||
written.delete(k);
|
||||
if (isUserData(k)) persisted.set(k, display);
|
||||
}
|
||||
}
|
||||
export const unlinkSync = rmSync;
|
||||
export const rmdirSync = rmSync;
|
||||
export function copyFileSync(a, b) {
|
||||
writeFileSync(b, readFileSync(a));
|
||||
}
|
||||
export function renameSync(a, b) {
|
||||
writeFileSync(b, readFileSync(a));
|
||||
overlay.delete(low(a));
|
||||
}
|
||||
export function watchFile() {}
|
||||
export function unwatchFile() {}
|
||||
|
||||
/** Any open mode works for reading; writes through the fd fail until write support exists. */
|
||||
export function openSync(p) {
|
||||
const f = fileSync(p);
|
||||
const fd = nextFd++;
|
||||
fds.set(fd, { path: p, file: f });
|
||||
return fd;
|
||||
}
|
||||
|
||||
export function closeSync(fd) {
|
||||
fds.delete(fd);
|
||||
}
|
||||
|
||||
export function fstatSync(fd) {
|
||||
const o = fds.get(fd);
|
||||
if (!o) throw new Error("EBADF");
|
||||
return { size: o.file.size, mtimeMs: o.file.lastModified, isFile: () => true, isDirectory: () => false };
|
||||
}
|
||||
|
||||
/** fs.readSync(fd, buffer, offset, length, position) */
|
||||
export function readSync(fd, buffer, offset, length, position) {
|
||||
const o = fds.get(fd);
|
||||
if (!o) throw new Error("EBADF");
|
||||
const end = Math.min(o.file.size, position + length);
|
||||
if (end <= position) return 0;
|
||||
const chunk = new Uint8Array(readRange(o.file, position, end));
|
||||
buffer.set(chunk, offset);
|
||||
return chunk.length;
|
||||
}
|
||||
|
||||
export function writeSync() {
|
||||
const e = new Error("Writing into files is not available yet");
|
||||
e.code = "EROFS";
|
||||
throw e;
|
||||
}
|
||||
export const ftruncateSync = writeSync;
|
||||
export function fsyncSync() {}
|
||||
|
||||
export function realpathSync(p) {
|
||||
return norm(p);
|
||||
}
|
||||
export function accessSync(p) {
|
||||
if (!existsSync(p)) throw enoent(p);
|
||||
}
|
||||
export const lstatSync = statSync;
|
||||
export const constants = { F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1 };
|
||||
|
||||
export default {
|
||||
existsSync, statSync, lstatSync, readdirSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, mkdtempSync, rmSync, unlinkSync,
|
||||
rmdirSync, copyFileSync, renameSync, realpathSync, accessSync, constants, watchFile, unwatchFile, openSync, closeSync, fstatSync,
|
||||
readSync, writeSync, ftruncateSync, fsyncSync,
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
// The "main process" of the web version. The UI thread calls handlers by channel name, like
|
||||
// Electron IPC in the desktop app. Handlers run the ported synchronous modules; when they touch a
|
||||
// folder or file that is not resolved yet (vfs.NeedResolve), it is resolved and the call runs again.
|
||||
import "./setup.js";
|
||||
// Same module URL as the "node:fs" alias the ported modules use: one shared vfs state.
|
||||
import * as vfs from "/src/worker/vfs.js";
|
||||
import { Pack } from "./lib/core/iop.js";
|
||||
import { detectKey, setCustomKeys, allKeys } from "./keys.js";
|
||||
import assets from "./lib/main/assets.js";
|
||||
import * as ims from "./lib/main/imagesets.js";
|
||||
import iopman from "./lib/main/iopman.js";
|
||||
import { register } from "./lib/main/handlers.js";
|
||||
import dattable from "./lib/main/dattable.js";
|
||||
import codec from "./lib/main/lsmon/codec.js";
|
||||
import items from "./lib/main/items.js";
|
||||
import { registerIop } from "./iop-web.js";
|
||||
|
||||
const GAME = "/game";
|
||||
const packs = new Map(); // lower file name -> { pack, key, encrypted }
|
||||
|
||||
// ---------- progress of the running call (the page shows "NN%" on its loading overlay) ----------
|
||||
let activeId = 0;
|
||||
let lastSent = 0;
|
||||
/** done of total steps of the call being run; sent at most every 80 ms (and at the end). */
|
||||
function progress(done, total) {
|
||||
const now = performance.now();
|
||||
if (done < total && now - lastSent < 80) return;
|
||||
lastSent = now;
|
||||
self.postMessage({ progress: { id: activeId, done, total } });
|
||||
}
|
||||
// the ported modules report their long loops through this hook (e.g. models.js index build)
|
||||
globalThis.__lsProgress = progress;
|
||||
|
||||
/** Opens every pack in the game folder's root once (read-only) and registers them as sessions. */
|
||||
function openGamePacks() {
|
||||
const names = vfs.readdirSync(GAME).filter((n) => /\.iop$/i.test(n));
|
||||
const errors = new Map();
|
||||
for (const [i, name] of names.entries()) {
|
||||
progress(i, names.length);
|
||||
if (packs.has(name.toLowerCase())) continue;
|
||||
try {
|
||||
const pack = Pack.open(`${GAME}/${name}`, { readOnly: true });
|
||||
packs.set(name.toLowerCase(), { name, pack, ...detectKey(pack) });
|
||||
} catch (err) {
|
||||
if (err instanceof vfs.NeedResolve) throw err;
|
||||
errors.set(name, String(err.message || err));
|
||||
}
|
||||
}
|
||||
iopman.setGamePacks([...packs.values()].map((p) => ({ name: p.name, file: `${GAME}/${p.name}`, pack: p.pack, key: p.key, encrypted: p.encrypted })));
|
||||
return { names, errors };
|
||||
}
|
||||
|
||||
function closeGamePacks() {
|
||||
for (const p of packs.values()) p.pack.close();
|
||||
packs.clear();
|
||||
iopman.setGamePacks([]);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
"game:mount"(handle) {
|
||||
closeGamePacks();
|
||||
vfs.mount("game", handle);
|
||||
return { name: handle.name };
|
||||
},
|
||||
|
||||
/** Read-only fallback: a folder given as Files (<input webkitdirectory>, drag and drop, tests). */
|
||||
"game:mountFiles"(name, items) {
|
||||
closeGamePacks();
|
||||
vfs.mountFiles("game", items);
|
||||
return { name, readOnly: true };
|
||||
},
|
||||
|
||||
/** Dev only (?devgame=): files read piecewise over HTTP Range from the dev server. */
|
||||
"game:mountUrls"(name, items) {
|
||||
closeGamePacks();
|
||||
vfs.mountFiles("game", items.map((it) => ({ path: it.path, file: vfs.remoteFile(it.url, it.size, it.path) })));
|
||||
return { name, readOnly: true };
|
||||
},
|
||||
|
||||
// ---------- asset sources (same channels as the desktop) ----------
|
||||
"ims:loadFile": (file) => ims.loadFromFile(file),
|
||||
"ims:texture": (src, file) => ims.texture(src, file),
|
||||
"ims:describeSource": (src) => ims.describe(src),
|
||||
"ims:reloadTextures": (src) => ims.reloadTextures(src),
|
||||
"mdl:read": (src, name, folder, strict) => assets.read(src, name, folder, strict),
|
||||
"mdl:readKey": (src, key) => assets.readKey(src, key),
|
||||
"mdl:readPath": (src, suffix) => assets.readPath(src, suffix),
|
||||
"mdl:texture": (src, name) => assets.texture(src, name),
|
||||
"mdl:describe": (src) => assets.describe(src),
|
||||
"xml:list": (src) => assets.list(src).filter((it) => /(^|\/)xml\/.+\.xml$/i.test(it.path)).map((it) => [it.key, it.path, it.pack || ""]),
|
||||
|
||||
/** Dev check: the steps assets.packSource takes, with their errors. */
|
||||
"debug:packSource"(folder) {
|
||||
const steps = [];
|
||||
try {
|
||||
const names = vfs.readdirSync(folder);
|
||||
steps.push(["readdir", names]);
|
||||
for (const n of names.filter((x) => /\.iop$/i.test(x))) {
|
||||
const full = folder.replace(/\/$/, "") + "/" + n;
|
||||
try {
|
||||
steps.push(["stat " + n, vfs.statSync(full).isFile()]);
|
||||
const p = Pack.open(full, { readOnly: true });
|
||||
steps.push(["open " + n, p.entries.length]);
|
||||
p.close();
|
||||
} catch (err) {
|
||||
steps.push(["error " + n, `${err.constructor.name}: ${err.message}`]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push(["error", `${err.constructor.name}: ${err.message}`]);
|
||||
}
|
||||
return steps;
|
||||
},
|
||||
|
||||
/** Dev check: read every entry matching `re` with each password type; reports failures. */
|
||||
"debug:readAll"(re) {
|
||||
openGamePacks();
|
||||
const rx = new RegExp(re, "i");
|
||||
const out = [];
|
||||
for (const rec of packs.values()) {
|
||||
for (const e of rec.pack.liveEntries()) {
|
||||
if (!rx.test(e.name)) continue;
|
||||
const res = [0, 1].map((t) => {
|
||||
try {
|
||||
rec.pack.read(e, rec.key?.passwords[t] ?? null);
|
||||
return "ok";
|
||||
} catch (err) {
|
||||
return err.message;
|
||||
}
|
||||
});
|
||||
if (res[0] !== "ok" && res[1] !== "ok") out.push({ pack: rec.name, name: e.name, comment: e.comment.toString("latin1"), method: e.method, flags: e.flags, csize: e.csize, usize: e.usize, res });
|
||||
else if (res[0] !== "ok" || res[1] !== "ok") out.push({ pack: rec.name, name: e.name, comment: e.comment.toString("latin1"), res, oneOk: true });
|
||||
}
|
||||
}
|
||||
return { failures: out.filter((x) => !x.oneOk).length, mixed: out.filter((x) => x.oneOk).length, sample: out.slice(0, 6) };
|
||||
},
|
||||
|
||||
/** Dev: log every call's start (console.debug) to find one that hangs. */
|
||||
"debug:trace"(on) {
|
||||
self.__traceCalls = !!on;
|
||||
return self.__traceCalls;
|
||||
},
|
||||
|
||||
/** Dev check: time each step of opening one pack. */
|
||||
"debug:openPack"(name) {
|
||||
const t = [performance.now()];
|
||||
const names = vfs.readdirSync(GAME);
|
||||
t.push(performance.now());
|
||||
const pack = Pack.open(`${GAME}/${name}`, { readOnly: true });
|
||||
t.push(performance.now());
|
||||
const live = pack.liveEntries().length;
|
||||
t.push(performance.now());
|
||||
const det = detectKey(pack);
|
||||
t.push(performance.now());
|
||||
pack.close();
|
||||
return { names, entries: pack.entries.length, live, key: det.key && det.key.name, ms: t.slice(1).map((x, i) => Math.round(x - t[i])) };
|
||||
},
|
||||
|
||||
// ---------- extra mounts for the desktop dialogs (picked folders and files) ----------
|
||||
"vfs:mountHandle"(name, handle) {
|
||||
vfs.mount(name, handle);
|
||||
return `/${name}`;
|
||||
},
|
||||
"vfs:mountFiles"(name, items) {
|
||||
vfs.mountFiles(name, items);
|
||||
return `/${name}`;
|
||||
},
|
||||
/** Every key profile is read through here so packs opened by any tool find their password. */
|
||||
"keys:list": () => allKeys(),
|
||||
|
||||
/** "packs" when the game folder has packs (by content, any extension), "dir" when it holds unpacked files. */
|
||||
"game:kind"() {
|
||||
const names = vfs.readdirSync(GAME);
|
||||
for (const n of names) {
|
||||
if (!vfs.statSync(`${GAME}/${n}`).isFile()) continue;
|
||||
if (/\.iop$/i.test(n)) return "packs";
|
||||
}
|
||||
const lower = names.map((n) => n.toLowerCase());
|
||||
if (lower.includes("config") || lower.includes("resource") || lower.includes("xml")) return "dir";
|
||||
return "packs";
|
||||
},
|
||||
|
||||
/** The visitor's own passwords (tried first); the game packs are detected again with them. */
|
||||
"keys:set"(list) {
|
||||
setCustomKeys(list);
|
||||
closeGamePacks();
|
||||
return allKeys().map((k) => ({ id: k.id, name: k.name, builtin: !!k.builtin }));
|
||||
},
|
||||
|
||||
/** Every .iop in the game folder's root, opened read-only, with its key detected. */
|
||||
"packs:list"() {
|
||||
const { names, errors } = openGamePacks();
|
||||
return names
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((name) => {
|
||||
if (errors.has(name)) return { name, error: errors.get(name) };
|
||||
const rec = packs.get(name.toLowerCase());
|
||||
const live = rec.pack.liveEntries().length;
|
||||
return { name, size: rec.pack.size, entries: rec.pack.entries.length, live, overwritten: rec.pack.entries.length - live, key: rec.key ? rec.key.name : null, encrypted: rec.encrypted };
|
||||
});
|
||||
},
|
||||
|
||||
/** IOP Manager sessions = the game folder's packs (opened on first use). */
|
||||
"iop:sessions"() {
|
||||
if (vfs.mountNames().some((m) => m.toLowerCase() === "game")) openGamePacks();
|
||||
return iopman.sessions();
|
||||
},
|
||||
"ims:loadPack": (sessionId, entry) => ims.loadFromPack(sessionId, entry),
|
||||
|
||||
/** Tables: the open table written in the layout of the other side's .dat (client / server). */
|
||||
"dat:exportFor": (id, rows, target, name) => dattable.exportFor(id, rows, target, `/out-dat/${String(name || "table.dat").split(/[\\/]/).pop()}`),
|
||||
};
|
||||
|
||||
// ---------- every other desktop channel (generated from main/index.js registerIpc) ----------
|
||||
// handle(channel, fn) = plain call; task(channel, fn) = long job: the UI passes a task id first,
|
||||
// fn gets (...args, emit, signal), progress goes to the UI as { task } messages.
|
||||
const taskControllers = new Map();
|
||||
const generated = {};
|
||||
register(
|
||||
(channel, fn) => {
|
||||
generated[channel] = fn;
|
||||
},
|
||||
(channel, fn) => {
|
||||
generated[channel] = async (taskId, ...args) => {
|
||||
const controller = new AbortController();
|
||||
taskControllers.set(taskId, controller);
|
||||
const emit = (p) => self.postMessage({ task: { taskId, ...p } });
|
||||
try {
|
||||
return await fn(...args, emit, controller.signal);
|
||||
} finally {
|
||||
taskControllers.delete(taskId);
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
generated["task:cancel"] = (taskId) => taskControllers.get(taskId)?.abort();
|
||||
// The worker's own channels win over the generated ones (game folder, IOP sessions, sources).
|
||||
for (const [channel, fn] of Object.entries(generated)) if (!handlers[channel]) handlers[channel] = fn;
|
||||
// IOP Manager (web): iopw:* channels
|
||||
registerIop(handlers, { openGamePacks, packs, progress, GAME });
|
||||
|
||||
// ---------- Textures: the game folder's textures as a virtual folder ----------
|
||||
// The desktop Texture Studio works on a disk folder (tex:list + mdl:readFile + mdl:writeFiles).
|
||||
// "/game-textures" lists every texture of the game folder (packs: resource/texture of rs.iop,
|
||||
// map.iop… with the client's lookup order; unpacked: the files) and reads them through assets, so
|
||||
// .lsc / scrambled .dds open like in the game. Saving there writes a download (and the edited
|
||||
// copy is what opens next time in this visit).
|
||||
const GAME_TEX = "/game-textures";
|
||||
const TEX_RE = /(^|\/)texture\/[^/]+\.(dds|tga|png|jpe?g|bmp|lsc)$/i;
|
||||
const gameSrc = () => ({ kind: handlers["game:kind"]() === "dir" ? "dir" : "packs", folder: GAME });
|
||||
const isGameTex = (p) => String(p || "").replace(/\\/g, "/").toLowerCase().startsWith(GAME_TEX + "/") || String(p || "").toLowerCase() === GAME_TEX;
|
||||
{
|
||||
const texList = handlers["tex:list"];
|
||||
const readFile = handlers["mdl:readFile"];
|
||||
handlers["tex:list"] = (dir) => {
|
||||
if (!isGameTex(dir)) return texList(dir);
|
||||
const src = gameSrc();
|
||||
if (src.kind === "packs") openGamePacks();
|
||||
const seen = new Map();
|
||||
for (const it of assets.list(src)) {
|
||||
if (!TEX_RE.test(it.path)) continue;
|
||||
const name = it.path.split("/").pop();
|
||||
if (seen.has(name.toLowerCase())) continue; // the copy the client loads is the one read
|
||||
let size = 0;
|
||||
if (it.pack) size = packs.get(it.pack.toLowerCase())?.pack.lookup(it.path)?.usize || 0;
|
||||
else {
|
||||
try {
|
||||
size = vfs.statSync(`${GAME}/${it.path}`).size;
|
||||
} catch {
|
||||
// listed only
|
||||
}
|
||||
}
|
||||
seen.set(name.toLowerCase(), { name, size, mtime: 0 });
|
||||
}
|
||||
return [...seen.values()];
|
||||
};
|
||||
handlers["mdl:readFile"] = (file) => {
|
||||
if (!isGameTex(file)) return readFile(file);
|
||||
const ov = vfs.overlayFile(file);
|
||||
if (ov) return { bytes: ov.data, source: file, where: { kind: "file", file } };
|
||||
const name = String(file).replace(/\\/g, "/").split("/").pop();
|
||||
const hit = assets.texture(gameSrc(), name);
|
||||
if (!hit) throw new Error(`${name} isn't in the game folder.`);
|
||||
return { bytes: hit.bytes, source: hit.source, where: { kind: "file", file } };
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- STR(n) texts for a few config files (Heroes) without loading every item ----------
|
||||
// The game's text table (the region's own first, like Items) → { "ini_<stem>::<section>_<n>": text }.
|
||||
const textTables = new Map(); // source key → { table, decoder }
|
||||
handlers["text:forStems"] = (src, stems) => {
|
||||
const key = `${src.kind}:${src.folder}`;
|
||||
if (!textTables.has(key)) {
|
||||
const t = items.textFiles(src)[0];
|
||||
const hit = t ? assets.readKey(src, t.key) : null;
|
||||
textTables.set(key, { table: hit ? items.parseTextTable(Buffer.from(hit.bytes)) : new Map(), decoder: codec.textDecoder(t?.encoding || "euc-kr") });
|
||||
}
|
||||
const { table, decoder } = textTables.get(key);
|
||||
const prefixes = (stems || []).map((s) => `ini_${String(s).toLowerCase()}::`);
|
||||
const out = {};
|
||||
for (const [k, bytes] of table) if (prefixes.some((p) => k.startsWith(p))) out[k] = decoder.decode(bytes).replace(/\\r/g, "").replace(/\\n/g, " ");
|
||||
return out;
|
||||
};
|
||||
|
||||
// Heroes list with progress (same logic as the desktop hero:list: one config read per hero).
|
||||
handlers["hero:list"] = (src) => {
|
||||
const rows = assets.list(src).filter((it) => /(^|\/)config\/mercenary\/(\d{3})\/\2_item\.ini$/i.test(it.path));
|
||||
return rows
|
||||
.map((it, i) => {
|
||||
progress(i, rows.length);
|
||||
const id = /mercenary\/(\d{3})\//i.exec(it.path)[1];
|
||||
const hit = assets.readKey(src, it.key);
|
||||
const raw = hit ? Buffer.from(hit.bytes).toString("latin1") : "";
|
||||
const name = /;[-\s/]*\d{3}\.\s*([^\r\n;]+)/.exec(raw)?.[1]?.trim() || /\[item1\][\s\S]*?^\s*name\s*=\s*([^\r\n;]+)/im.exec(raw)?.[1]?.trim() || `Hero ${id}`;
|
||||
// names are bytes of the config: Korean ones read as Korean, the rest as Latin
|
||||
return [id, codec.decodeText(Buffer.from(name, "latin1"), "windows-1252"), it.pack || ""];
|
||||
})
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]));
|
||||
};
|
||||
|
||||
// /userdata (FX Lab presets, settings) survives reloads: the page keeps it in IndexedDB.
|
||||
handlers["userdata:restore"] = (list) => {
|
||||
vfs.restore(list);
|
||||
return true;
|
||||
};
|
||||
|
||||
async function run(channel, args, id) {
|
||||
const fn = handlers[channel];
|
||||
if (!fn) throw Object.assign(new Error(`Unknown channel ${channel}`), { code: "nochannel" });
|
||||
for (let attempt = 0; attempt < 10000; attempt++) {
|
||||
try {
|
||||
activeId = id;
|
||||
return await fn(...args);
|
||||
} catch (err) {
|
||||
if (!(err instanceof vfs.NeedResolve)) throw err;
|
||||
await vfs.resolve(err);
|
||||
}
|
||||
}
|
||||
throw new Error(`${channel}: too many file lookups`);
|
||||
}
|
||||
|
||||
self.onmessage = async ({ data }) => {
|
||||
const { id, channel, args } = data;
|
||||
vfs.takeWritten(); // start each call with an empty write log
|
||||
// dev: every call that takes long is logged with its arguments
|
||||
const t0 = performance.now();
|
||||
// (a timer can't fire while a synchronous handler runs, so the start is logged instead)
|
||||
if (import.meta.env.DEV && self.__traceCalls) console.debug(`[call ${id}] ${channel}`, JSON.stringify(args).slice(0, 300));
|
||||
const slow = 0;
|
||||
try {
|
||||
const value = await run(channel, args || [], id);
|
||||
clearTimeout(slow);
|
||||
if (import.meta.env.DEV && performance.now() - t0 > 3000) console.warn(`[slow done] ${channel} ${Math.round(performance.now() - t0)} ms`);
|
||||
self.postMessage({ id, ok: true, value, written: vfs.takeWritten(), persisted: vfs.takePersisted() });
|
||||
} catch (err) {
|
||||
if (!(err && err.code === "nochannel")) console.error(`[${channel}]`, err);
|
||||
self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err), code: err && err.code, stack: err && err.stack, written: vfs.takeWritten(), persisted: vfs.takePersisted() });
|
||||
}
|
||||
};
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user