From 9e1b591e5f315ff2f2ef7e4ad6fe6f561c781450 Mon Sep 17 00:00:00 2001 From: KinjerJS Date: Mon, 17 Aug 2026 13:44:08 +0200 Subject: [PATCH 1/2] feat: guided setup, 1.8.9 live editing, version-bound projects and undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapters and install - New Forge 1.8.9 adapter covering [1.8.8, 1.9), verified in game: the mod loads, blocks register and place. It builds on ForgeGradle 2.1 with its own Gradle wrapper, since RetroFuturaGradle covers only 1.7.10 and 1.12.2, and CI builds it under Java 8. - Adapter coverage is a table of narrow ranges carrying a built or planned status, so the launcher never offers live editing it cannot deliver. A range claims only the versions its jar was compiled for. - Pre-2018 Forge installers have no headless client mode. Which path an installer needs is read from its own install_profile.json rather than guessed from a version number, and that generation is unpacked directly. - Pressing Stop is no longer reported as a crash: an exit is classified from whether it was requested, its code and its signal. Launcher - Opens on a five-step guide that reads live state instead of ticking checkboxes, so it cannot claim something is done when it is not. Once all five pass it folds into a dashboard. - Animated splash while the launcher starts, empty states, toasts in place of inline banners, and a reason in the tooltip of every disabled control. - Block and item previews rotate with the pointer: the projection takes yaw and pitch, culls faces by normal, and fits at any angle. - A project targets a Minecraft version, preselected when it opens. Launching another one lists what would break in its files and offers to rewrite them: the 1.9 parent trap and the 1.13 vanilla texture folders. - A model declaring both a parent and its own elements renders as its parent below 1.9 — detected in the editor and fixable in one click. - Changes announce themselves with an Undo action: deletes, renames, display names, texture removals, model rewrites. Inverses live in the main process; deleted files wait in a per-session stash. - Error banners can be dismissed and clear once they stop applying. - Ella 0.2.0, shown in the sidebar and checked against package.json. Docs and housekeeping - architecture.md, and why 1.7.10 and older cannot be supported: the JSON model system arrived in 1.8, so there is no file to edit. - README covers the guided setup, the version binding and undo. - Stop tracking tsc's incremental cache; it describes one machine's last build and conflicts on every branch. 304 launcher tests pass. BREAKING CHANGE: project.json replaces `targetVersions` with `targetVersion`; manifests carrying the array are migrated on load. --- .github/workflows/build.yml | 9 + .gitignore | 9 +- README.md | 42 +- docs/architecture.md | 113 ++ docs/building.md | 38 +- docs/project-format.md | 15 +- docs/protocol.md | 2 +- launcher/locales/en.json | 182 ++- launcher/locales/fr.json | 182 ++- launcher/package-lock.json | 4 +- launcher/package.json | 2 +- launcher/src/main/blockbench.ts | 14 +- launcher/src/main/diagnostics.ts | 4 +- launcher/src/main/index.ts | 195 ++- launcher/src/main/minecraft/forge.ts | 127 +- launcher/src/main/minecraft/install.ts | 66 +- launcher/src/main/minecraft/launch.ts | 47 +- launcher/src/main/pack.ts | 201 ++- launcher/src/main/project.ts | 158 +- launcher/src/main/session.ts | 145 +- launcher/src/main/splash.ts | 220 +++ launcher/src/main/textures.ts | 60 +- launcher/src/main/trash.ts | 120 ++ launcher/src/main/undo.ts | 77 + launcher/src/main/version-change.ts | 141 ++ launcher/src/preload/index.ts | 14 +- launcher/src/renderer/src/App.tsx | 146 +- .../renderer/src/components/EmptyState.tsx | 39 + .../renderer/src/components/EntryHeader.tsx | 9 +- .../renderer/src/components/ErrorBanner.tsx | 40 + launcher/src/renderer/src/components/Icon.tsx | 28 +- .../renderer/src/components/ModelPreview.tsx | 170 +- .../renderer/src/components/QuickLaunch.tsx | 63 +- .../renderer/src/components/QuickNewEntry.tsx | 86 +- .../renderer/src/components/SettingsForm.tsx | 131 +- .../src/renderer/src/components/StatusBar.tsx | 45 +- .../renderer/src/components/TexturePanel.tsx | 12 +- .../src/renderer/src/components/Toast.tsx | 187 +++ .../src/components/VersionChangeDialog.tsx | 170 ++ launcher/src/renderer/src/facts.ts | 66 + launcher/src/renderer/src/main.tsx | 5 +- launcher/src/renderer/src/navigation.ts | 32 + launcher/src/renderer/src/session.ts | 66 +- launcher/src/renderer/src/styles.css | 1442 ++++++++++++++--- launcher/src/renderer/src/versions.ts | 22 + .../src/renderer/src/views/EditorView.tsx | 263 ++- .../src/renderer/src/views/ExportView.tsx | 85 +- launcher/src/renderer/src/views/HomeView.tsx | 363 +++++ launcher/src/renderer/src/views/LogsView.tsx | 20 +- .../src/renderer/src/views/ProjectView.tsx | 617 ++++--- .../src/renderer/src/views/SettingsView.tsx | 174 +- .../src/renderer/src/views/VersionsView.tsx | 241 ++- launcher/src/shared/app.ts | 14 + launcher/src/shared/ipc.ts | 87 +- launcher/src/shared/model-compat.ts | 68 + launcher/src/shared/model-preview.ts | 339 +++- launcher/src/shared/project.ts | 28 +- launcher/src/shared/version-compat.ts | 240 +++ launcher/src/shared/version.ts | 11 +- launcher/src/shared/workflow.ts | 72 + launcher/test/adapter-ranges.test.ts | 75 + launcher/test/app.test.ts | 26 + launcher/test/forge-install.test.ts | 77 + launcher/test/model-compat.test.ts | 76 + launcher/test/model-preview.test.ts | 142 +- launcher/test/pack-project.test.ts | 113 +- launcher/test/session-exit.test.ts | 39 + launcher/test/textures.test.ts | 20 +- launcher/test/undo.test.ts | 354 ++++ launcher/test/version-compat.test.ts | 334 ++++ launcher/test/version.test.ts | 14 +- launcher/test/workflow.test.ts | 85 + launcher/tsconfig.node.tsbuildinfo | 1 - launcher/tsconfig.web.tsbuildinfo | 1 - mod/adapters/forge-1.12.2/build.gradle | 2 +- .../main/java/dev/ella/forge112/EllaMod.java | 11 +- mod/adapters/forge-1.8.9/build.gradle | 78 + mod/adapters/forge-1.8.9/gradle.properties | 8 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63375 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + mod/adapters/forge-1.8.9/gradlew | 248 +++ mod/adapters/forge-1.8.9/gradlew.bat | 92 ++ .../java/dev/ella/forge189/EllaBlock.java | 251 +++ .../main/java/dev/ella/forge189/EllaItem.java | 82 + .../java/dev/ella/forge189/EllaItemBlock.java | 57 + .../java/dev/ella/forge189/EllaLogBridge.java | 44 + .../main/java/dev/ella/forge189/EllaMod.java | 107 ++ .../java/dev/ella/forge189/EllaResources.java | 98 ++ .../java/dev/ella/forge189/ForgeHost.java | 279 ++++ .../java/dev/ella/forge189/Registration.java | 127 ++ .../main/java/dev/ella/forge189/Sounds.java | 35 + .../forge-1.8.9/src/main/resources/mcmod.info | 15 + .../src/main/resources/pack.mcmeta | 6 + mod/adapters/forge-modern/build.gradle | 2 +- .../java/dev/ella/forgemodern/EllaMod.java | 2 +- mod/ella-core/build.gradle | 2 +- 96 files changed, 9360 insertions(+), 1118 deletions(-) create mode 100644 launcher/src/main/splash.ts create mode 100644 launcher/src/main/trash.ts create mode 100644 launcher/src/main/undo.ts create mode 100644 launcher/src/main/version-change.ts create mode 100644 launcher/src/renderer/src/components/EmptyState.tsx create mode 100644 launcher/src/renderer/src/components/ErrorBanner.tsx create mode 100644 launcher/src/renderer/src/components/Toast.tsx create mode 100644 launcher/src/renderer/src/components/VersionChangeDialog.tsx create mode 100644 launcher/src/renderer/src/facts.ts create mode 100644 launcher/src/renderer/src/navigation.ts create mode 100644 launcher/src/renderer/src/versions.ts create mode 100644 launcher/src/renderer/src/views/HomeView.tsx create mode 100644 launcher/src/shared/app.ts create mode 100644 launcher/src/shared/model-compat.ts create mode 100644 launcher/src/shared/version-compat.ts create mode 100644 launcher/src/shared/workflow.ts create mode 100644 launcher/test/adapter-ranges.test.ts create mode 100644 launcher/test/app.test.ts create mode 100644 launcher/test/forge-install.test.ts create mode 100644 launcher/test/model-compat.test.ts create mode 100644 launcher/test/session-exit.test.ts create mode 100644 launcher/test/undo.test.ts create mode 100644 launcher/test/version-compat.test.ts create mode 100644 launcher/test/workflow.test.ts delete mode 100644 launcher/tsconfig.node.tsbuildinfo delete mode 100644 launcher/tsconfig.web.tsbuildinfo create mode 100644 mod/adapters/forge-1.8.9/build.gradle create mode 100644 mod/adapters/forge-1.8.9/gradle.properties create mode 100644 mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.jar create mode 100644 mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.properties create mode 100644 mod/adapters/forge-1.8.9/gradlew create mode 100644 mod/adapters/forge-1.8.9/gradlew.bat create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java create mode 100644 mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java create mode 100644 mod/adapters/forge-1.8.9/src/main/resources/mcmod.info create mode 100644 mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66ab893..1a3d665 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -91,6 +91,15 @@ jobs: # setup-java left in JAVA_HOME: RetroFuturaGradle and ForgeGradle run game tooling # inside the Gradle process itself, so the daemon's JVM is part of the build # definition. `-D` on the command line overrides the project's gradle.properties. + # The odd one out: RetroFuturaGradle covers only 1.7.10 and 1.12.2, so 1.8.9 has to + # use the era-correct ForgeGradle 2.1 — which needs Gradle 2.14 on Java 8. That is + # why this adapter carries its own wrapper and ignores the pinned Gradle above. + - name: Build forge-1.8.9 + working-directory: mod/adapters/forge-1.8.9 + env: + JAVA_HOME: ${{ env.JAVA_HOME_8_X64 }} + run: ./gradlew build --no-daemon + - name: Build forge-1.12.2 working-directory: mod/adapters/forge-1.12.2 run: gradle build ${{ env.TOOLCHAINS }} -Dorg.gradle.java.home=$JAVA_HOME_17_X64 diff --git a/.gitignore b/.gitignore index 057a7c2..025d232 100644 --- a/.gitignore +++ b/.gitignore @@ -12,13 +12,20 @@ release/ launcher/build/ launcher/resources/ +# tsc's incremental cache. It changes on every typecheck and describes one machine's last +# build, so committing it makes every branch conflict over a file nobody reads. +*.tsbuildinfo + # Java / Gradle build/ .gradle/ bin/ *.class *.jar -!gradle/wrapper/gradle-wrapper.jar +# Anchored with `**/` so it reaches an adapter's own wrapper, not just a root one. The +# 1.8.9 adapter is the only build here that needs a wrapper — without its jar committed, a +# fresh clone and CI both fail at `./gradlew`. +!**/gradle/wrapper/gradle-wrapper.jar # Forge / Minecraft dev run/ diff --git a/README.md b/README.md index 190a78b..75ca776 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,27 @@ Targets **Minecraft 1.8.9 → 26.2** on Forge. ## Status -Early but working end to end in code: the launcher builds and runs, both adapter jars -compile against real Forge toolchains, and 202 launcher tests plus 23 Java tests pass. +Early but working end to end in code: the launcher builds and runs, all three built adapter +jars compile against real Forge toolchains, and 304 launcher tests plus 23 Java tests pass. + +Ella opens on a five-step guide — install a version, create a project, add a block, point +at Blockbench, launch — where every step reads its own state rather than being ticked, so +it can never claim something is done when it is not, and a step that stops being true goes +back to undone on its own. Once all five pass, the guide folds away and the same space +becomes a dashboard. Every control that is disabled says in its tooltip why, because a +greyed-out button with no reason reads as a broken app rather than a missing prerequisite. + +**A project is bound to the version it was authored for.** Opening it preselects that +version, and launching a different one stops first to say what the change would do — listed +against the project's own files, entry by entry, with an offer to rewrite the ones Ella can. +The two divergences that matter are both silent in game: a `parent` overrides a model's own +geometry below 1.9, and vanilla's texture folders were renamed in 1.13. Finding either from +a black block in the world is exactly what that dialog exists to prevent. + +**Changes that touch files can be taken back from the notification that reports them** — a +deletion, a rename, a texture variable removed, a model Ella rewrote. Files a deletion took +wait in a per-session stash rather than being destroyed, so the offer is real rather than a +promise the disk cannot keep. | Component | State | |---|---| @@ -22,7 +41,12 @@ compile against real Forge toolchains, and 202 launcher tests plus 23 Java tests | Adapter presence check on every launch | done | | Version install / uninstall | done | | Quick launch from any view | done | +| Guided setup — five steps read off live state | done | +| Animated splash while the launcher starts | done | | Project and entry deletion | done | +| Undo a change from its notification | done | +| Projects bound to a Minecraft version, with model migration | done | +| Turntable previews of blocks and items | done | | Block/item editor with capability gating | done | | Blockbench open + file watch | done | | Blockbench live-sync plugin | done | @@ -30,8 +54,10 @@ compile against real Forge toolchains, and 202 launcher tests plus 23 Java tests | Windows installer + portable `.exe` | done — built and smoke-tested | | GitHub Actions build & release workflow | written, not yet run against a remote | | Forge adapter 1.12.2 (`[1.12, 1.13)`) | **verified in game** — blocks render in hand and placed | +| Forge adapter 1.8.9 (`[1.8.8, 1.9)`) | **verified in game** — mod loads, blocks register and place | | Forge adapter 1.21.1 (`[1.21.1, 1.21.2)`) | builds and loads; in-game rendering not yet confirmed | -| Forge adapters 1.8.9 and 1.16.5–1.20.1 | not started | +| Forge adapter 1.16.5–1.20.1 | not started | +| Minecraft 1.7.10 and older | **not possible** — see below | | OBJ model source | designed for, not implemented | The full loop works on 1.12.2: the launcher installs Minecraft and Forge, picks the right @@ -43,6 +69,16 @@ in [`docs/building.md`](docs/building.md) and the cross-version traps in [`docs/architecture.md`](docs/architecture.md). Six of the seven were invisible to the compiler and only surfaced in a running game. +**1.7.10 and older cannot be supported, and the reason is not effort.** The JSON model +system arrived in 1.8: the 1.7.10 client jar contains zero model and blockstate files +against 1.8.9's 1935, and its `assets/minecraft/` holds only fonts, languages, shaders, +texts and textures. Block shape on 1.7.10 lives in Java code and item appearance in +atlas sprites, so there is no file for Blockbench to edit and no file for a save to +update. Ella could only fake it by reimplementing a model interpreter inside the mod — +which is precisely the "renders something close to what the file says" failure this +design exists to avoid. Texture-only live editing on plain cubes would be possible; ask +if that is worth having. + **Version coverage is narrow and deliberately honest.** An adapter claims only the versions it was compiled against, because Forge changed the block properties and item component APIs inside every bucket — "compiles for 1.21.1" says nothing about 1.21.11. diff --git a/docs/architecture.md b/docs/architecture.md index b29803e..cd598a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,6 +151,73 @@ that merge are easy to get wrong, and both fail far from their cause: Both mistakes share a shape worth remembering: a modded version id looks enough like a version to pass through code unnoticed, and fails somewhere else entirely. +## A parent overrides the child's geometry on 1.8.x + +`ModelBlock.getElements()` on 1.8.9 is, in full: + +```java +return this.hasParent() ? this.parent.getElements() : this.elements; +``` + +with `hasParent()` being nothing more than `parent != null`. The parent wins outright. That +is why vanilla 1.8.9's own `block/cube.json` declares **no** parent and inlines its +elements — it only gained `"parent": "block/block"` in 1.9, once the semantics flipped to +"the child's elements win if it has any". + +Blockbench keeps whatever parent it finds. So a model that starts life as +`{ parent: "block/cube_all" }` and then gains geometry carries that parent forever, and on +1.8.x renders as a plain cube — in the missing-texture checkerboard, because Blockbench +also rewrites the texture keys and the parent's `#all` stops resolving. The author sees a +magenta cube and reasonably concludes Ella lost their model. It loaded and was overruled. + +Two changes, because one alone is not enough: + +- The generated starting model is **self-contained** — its own full-cube elements, `all` + and `particle` declared, no parent. There is then no parent for Blockbench to keep. It + also has to declare `particle` explicitly, which `block/cube_all` used to supply for free. +- Models that already carry the trap are detected from the parsed model the editor already + holds, and the warning offers to drop the `parent`. Dropping it is the whole fix and + costs nothing: from 1.9 onwards that parent's geometry was being ignored anyway. + +Ella does not rewrite the author's file on its own. Saving in Blockbench has to stay the +only thing that changes it, so the fix is a button rather than a repair on load. + +## Two generations of Forge installer + +Ella runs the official Forge installer headlessly rather than reimplementing it. From 1.13 +onwards that is not a preference: installation runs binary-patch and deobfuscation +processors, and reproducing those would mean tracking a toolchain that is not ours. + +It stops being possible going the other way. `--installClient` was added to the installer +around 2018; older builds abort with *"'installClient' is not a recognized option"*. That +covers every Forge build for 1.8.x — so the whole 1.8 line installed as vanilla, and +because a Forge failure is collected as a warning rather than thrown, the version still +appeared installed. The symptom was "I have no Forge for 1.8.8", a long way from the cause. + +Those same builds predate the processors, so their install genuinely is just unpacking: +write `versionInfo` from `install_profile.json` as the version json — it carries +`inheritsFrom`, so the vanilla document supplies the rest — drop the universal jar at the +path `install.path` names inside `libraries/`, and download the twenty-odd libraries the +version file lists. + +That last step is the one to get wrong, because the official installer does it invisibly. +Skip it and the game dies before its window opens with +`NoClassDefFoundError: org/objectweb/asm/ClassVisitor`: ASM is listed in the 1.8.x Forge +file with no download url at all, so nothing else in the pipeline would ever fetch it. The +launcher already knows how to resolve the three shapes a library entry can take — a direct +url, a repository base, or nothing but a coordinate — so the legacy path reuses that code +rather than carrying a second copy of it. + +A missing jar does not stop Java from starting, which is why `buildLaunchCommand` now +refuses to launch with an incomplete classpath. Checking costs a few stat calls and turns +a stack trace naming a *class* into one sentence naming the *file* and the fix. + +Which path applies is read from the installer's own profile, not from the Minecraft +version: the old generation carries a `versionInfo` block, the new one carries +`processors`. The change came with an installer release rather than a game release, and +1.12.2 sits on the new side of it while 1.8.9 sits on the old one — a version-number rule +would have put them the wrong way round. + ## Install and uninstall Downloaded content falls into three groups, and the difference decides what an uninstall @@ -188,6 +255,52 @@ version cannot honour and says why. One UI serves the whole range honestly. See [`protocol.md`](protocol.md) and [`project-format.md`](project-format.md). +## Guided setup, derived rather than stored + +Ella's loop needs five things true at once — a version installed, a project open, an entry +in it, Blockbench reachable, and the mod connected — and the order is not guessable from +the navigation. The home view walks them as an ordered list. + +Nothing about that list is persisted. `shared/workflow.ts` takes a flat snapshot of the +same state the rest of the UI reads and returns which steps are done and which one is +current; there is no "onboarding completed" flag anywhere. Two things fall out of that: +a step cannot claim to be done when it is not, and a step that stops being true — the game +exits, the project is closed — reopens on its own without anything having to invalidate it. +It is also the reason the checklist is safe to keep showing after setup is complete, where +it collapses into a toggle rather than disappearing. + +The same principle covers disabled controls. Every one of them carries the reason in its +`title` — *launch the game first*, *Java 21 was not found*, *stop the running game* — +because the three states that dim the give/place buttons have three different fixes, and a +greyed-out button with no reason reads as a broken app rather than a missing prerequisite. + +Whether Blockbench is reachable is asked of the main process (`blockbench:resolve`) rather +than inferred from the configured path, since the usual case is an empty setting and a +successful auto-detection. + +## Two write paths for the slot namespace + +`writeSlotNamespace` wipes and regenerates everything: four files per block slot plus one +per item slot, so a default pool of 128 + 128 is 640 files, about 450 ms. That is the right +answer for a change that moves bindings, renames the namespace or deletes an entry, because +a stale blockstate left behind keeps rendering a ghost. + +It is the wrong answer for a settings change, which arrives once per slider tick. Settings +reach the pack through exactly one thing — the render layer, baked into that slot's +redirect model — so `writeEntrySlot` rewrites that slot alone, four files and about 2 ms. +`entries:patchLive` takes that path. + +Two related rules follow from the same reasoning: + +- The `ella` namespace is excluded from the file watcher. It is Ella's own output, and + watching it meant every settings change fed its own writes back in as a model change: + a reload the game did not need, plus a preview refresh in the editor for each one. +- The editor's settings form is driven by a local draft, not by the saved project. A + control bound to the round trip cannot follow the mouse. Writes are coalesced behind + the draft, one at a time, and a spinner reports that they are in flight — the controls + are never disabled while saving, since that would reintroduce exactly the stall the + draft exists to remove. + ## Language policy Code, comments, identifiers and documentation are English. Every user-facing string is diff --git a/docs/building.md b/docs/building.md index fef8ec1..cd13313 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,6 +1,6 @@ # Building Ella -Three independent builds. Nothing is shared through a repository — the adapters compile +Four independent builds. Nothing is shared through a repository — the adapters compile `ella-core` from source — so they can be built in any order. ## Launcher @@ -8,7 +8,7 @@ Three independent builds. Nothing is shared through a repository — the adapter ```bash cd launcher npm install -npm test # 202 tests, runs straight off the TypeScript sources +npm test # 232 tests, runs straight off the TypeScript sources npm run typecheck npm run dev # Electron with hot reload npm run build # production bundle into out/ @@ -38,7 +38,11 @@ instance's `mods` folder. ```bash cd mod/adapters/forge-1.12.2 && gradle build -cd mod/adapters/forge-modern && gradle build +cd mod/adapters/forge-modern && gradle build + +# The 1.8.9 adapter carries its own wrapper and needs a Java 8 JVM to run Gradle itself. +cd mod/adapters/forge-1.8.9 +JAVA_HOME=/path/to/jdk-8 ./gradlew build # or ./gradlew build -Dorg.gradle.java.home=... ``` Output lands in `build/libs/`. The launcher finds it automatically in development; there @@ -49,10 +53,24 @@ launcher explicitly filters it out for that reason. ### Toolchains -| Adapter | Plugin | Gradle JVM | Compiles to | -|---|---|---|---| -| `forge-1.12.2` | RetroFuturaGradle 1.4.x | Java 17 | Java 8 | -| `forge-modern` | ForgeGradle 6 | Java 21 | Java 21 | +| Adapter | Plugin | Gradle | Gradle JVM | Compiles to | +|---|---|---|---|---| +| `forge-1.8.9` | ForgeGradle 2.1 | 2.14.1 (own wrapper) | Java 8 | Java 8 | +| `forge-1.12.2` | RetroFuturaGradle 1.4.x | 8.2.1 | Java 17 | Java 8 | +| `forge-modern` | ForgeGradle 6 | 8.2.1 | Java 21 | Java 21 | + +**Why 1.8.9 is the odd one out.** RetroFuturaGradle — which is what lets 1.12.2 build on a +modern Gradle — supports exactly two Minecraft versions, 1.7.10 and 1.12.2, because those +are the ones its authors ship modpacks for. No release of it adds 1.8.9, so that adapter +has to use the era-correct ForgeGradle 2.1, which is pinned to Gradle 2.x and Java 8. It +therefore has its own wrapper: `./gradlew` in that directory launches Gradle 2.14.1, not +the 8.2.1 everything else uses. Adapters were always independent builds for exactly this +kind of reason. + +Its `gradle.properties` raises the heap to 3 GB. Gradle 2.14's default is far too small for +the 1.8.9 deobfuscation pass, which dies partway through `deobfMcMCP` with *GC overhead +limit exceeded* — a confusing failure, because nothing in the message suggests memory is +the fixable part. **Why RetroFuturaGradle for 1.12.2.** The original ForgeGradle 2.3 is pinned to Gradle 4.4 and Java 8. RFG provides the same deobfuscation toolchain on modern Gradle. It is pinned @@ -96,12 +114,12 @@ executable. Both are around 80 MB, which is Electron. **The adapters must be built first.** `stage:adapters` fails the build if it finds no jars at all, because an installer without them still launches Minecraft but silently loses live editing — the feature the tool exists for. Adapters that are merely *not written yet* -(`forge-1.8.9`, `forge-mid`) are skipped without complaint, and the launcher reports those +(`forge-mid`) are skipped without complaint, and the launcher reports those versions as vanilla-only at runtime. The jars are packaged as `extraResources`, so they sit next to the asar as ordinary files rather than inside it. The injector copies them into an instance's `mods` folder, and -keeping their real filename (`ella-forge-1.12.2-0.1.0.jar`) matters: the stale-jar cleanup +keeping their real filename (`ella-forge-1.12.2-0.2.0.jar`) matters: the stale-jar cleanup matches `ella-*.jar`, so renaming them would let two Ella mods accumulate in one instance. ### Signing @@ -145,7 +163,7 @@ JDKs `setup-java` installed and reports the Java 8 toolchain as missing. Cutting a release is one command: ```bash -git tag v0.1.0 && git push origin v0.1.0 +git tag v0.2.0 && git push origin v0.2.0 ``` ## Verifying an API before using it diff --git a/docs/project-format.md b/docs/project-format.md index f44432a..3213f68 100644 --- a/docs/project-format.md +++ b/docs/project-format.md @@ -28,14 +28,23 @@ the mod injects into the resource stack. "formatVersion": 1, "name": "My Project", "namespace": "myproject", - "targetVersions": ["1.12.2", "1.21.1"], + "targetVersion": "1.12.2", "slotPool": { "block": 128, "item": 128 }, "entries": [] } ``` -`targetVersions` drives validation: the editor warns when a setting is used that some -listed target cannot honour. +`targetVersion` is the Minecraft version the project is authored against. The launcher +preselects it whenever the project is opened, and launching any other version asks first — +listing what would break in this project's files, and offering to rewrite the ones it can. +See `shared/version-compat.ts` for what "break" means here; both cases are silent in game, +which is why they are worth a dialog. + +`null` means the project is not bound yet: the next launch adopts its version. That is how +a project created before the field existed acquires one. + +> Replaces a `targetVersions` array that nothing ever read past creation. A manifest still +> carrying it is migrated on load — the first entry becomes `targetVersion`. ## Entries diff --git a/docs/protocol.md b/docs/protocol.md index 3becd82..cb715c0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -54,7 +54,7 @@ Sent immediately on connect. "loader": "forge", "loaderVersion": "14.23.5.2859", "adapter": "forge-1.12.2", - "adapterVersion": "0.1.0", + "adapterVersion": "0.2.0", "javaVersion": "8", "slots": { "block": 128, "item": 128 }, "capabilities": ["render_layer.cutout", "..."] diff --git a/launcher/locales/en.json b/launcher/locales/en.json index 8840a97..fe6b518 100644 --- a/launcher/locales/en.json +++ b/launcher/locales/en.json @@ -2,10 +2,15 @@ "app.name": "Ella", "app.tagline": "A model-testing workbench for Blockbench authors", + "nav.home": "Home", + "nav.needsProject": "Open a project first", "nav.versions": "Versions", "nav.project": "Project", "nav.editor": "Editor", "nav.logs": "Logs", + "nav.export": "Export", + "nav.settings": "Settings", + "logs.filter": "Filter…", "logs.follow": "Follow", "logs.clear": "Clear", @@ -15,17 +20,71 @@ "logs.level.info": "Info and above", "logs.level.warn": "Warnings and errors", "logs.level.error": "Errors only", - "nav.export": "Export", - "nav.settings": "Settings", + + "home.welcome": "Welcome to Ella", + "home.welcomeText": "Model a block in Blockbench, save, and watch it change in the running game. Five steps and you are live.", + "home.projectSubtitle": "Namespace {namespace}. Everything you make lands under it.", + "home.stat.live": "Live editing", + "home.stat.minecraft": "Minecraft", + "home.stat.blockbench": "Blockbench", + "home.live.on": "Live", + "home.live.off": "Off", + "home.found": "Found", + "home.notFound": "Not found", + "home.noLiveVersion": "None of your installed versions can live-edit, so the game will launch but never pick up your changes.", + "home.noLiveVersionAction": "See which versions can", + "home.quickActions": "What now?", + "home.recent": "Your blocks and items", + "home.action.editor": "Open the editor", + "home.action.editorText": "Tune a block's look and behaviour while the game runs.", + "home.action.newText": "Add another block or item to the project.", + "home.action.exportText": "Package everything as a resource pack zip.", + "home.action.logsText": "Read what the game and the launcher are saying.", + "home.resume": "Pick up where you left off", + "home.resumeHelp": "Opening one of these puts its blocks and items back in the editor and reloads them into a running game.", + "home.newProjectText": "Start a fresh namespace of your own.", + + "guide.title": "Getting set up", + "guide.subtitle": "Ella needs all five of these before an edit in Blockbench can reach the running game. Each one checks itself — nothing here is a box you tick.", + "guide.progress": "{done} of {total}", + "guide.show": "Show the checklist", + "guide.hide": "Hide the checklist", + "guide.version.title": "Install a Minecraft version", + "guide.version.why": "Ella downloads and manages its own copy, so nothing it does can touch your normal installation.", + "guide.version.action": "Choose a version", + "guide.project.title": "Create a project", + "guide.project.why": "A project holds your models, textures and settings, and gives them the namespace the game loads them under.", + "guide.project.action": "Create one", + "guide.entry.title": "Add a block or an item", + "guide.entry.why": "Ella writes a starter model and texture for it, ready to open in Blockbench.", + "guide.entry.action": "Add one", + "guide.blockbench.title": "Point Ella at Blockbench", + "guide.blockbench.why": "Ella hands Blockbench the very model file the game reads, so saving is the update — there is no import or export in between.", + "guide.blockbench.action": "Set the path", + "guide.launch.title": "Launch the game", + "guide.launch.why": "Ella's mod connects back to the launcher on startup. Once it does, every save appears in game without a reload.", + "guide.launch.action": "Launch", + + "progress.download": "Downloading", + "progress.install": "Installing", + "progress.export": "Exporting", + + "status.adapter": "Running version, loader and Ella adapter", "versions.title": "Minecraft version", + "versions.subtitle": "Ella keeps its own installations, separate from the official launcher.", + "versions.recommended": "Ready for live editing", + "versions.recommendedHelp": "These versions have an Ella adapter built, so edits reach the running game. Any other version installs and launches, but only as plain Minecraft.", + "versions.refresh": "Refresh", + "versions.installing": "Installing…", + "versions.installDone": "Minecraft {version} installed", + "versions.uninstallDone": "Minecraft {version} uninstalled", + "versions.noneInstalledHelp": "Pick one from the list below. The first install downloads a few hundred megabytes; later ones reuse most of it.", + "versions.alreadyRunning": "Stop the running game first", + "versions.javaFoundHelp": "A matching Java runtime was found on this machine.", + "versions.vanillaOnlyHelp": "No Ella adapter covers this version, so it launches as plain Minecraft.", "versions.installed": "Installed", "versions.noneInstalled": "No version installed yet. Install one below to get started.", - "quick.version": "Version", - "quick.launch": "Launch", - "quick.stop": "Stop", - "quick.noVersions": "Install a version to launch from here.", - "quick.vanillaOnly": "No adapter for this version — launches without live editing.", "versions.available": "Available", "versions.showSnapshots": "Show snapshots", "versions.install": "Install", @@ -48,14 +107,38 @@ "versions.javaRequired": "Requires Java {java}", "versions.javaMissing": "Java {java} was not found on this machine", "versions.javaFound": "Using Java {version}", + "versions.repair": "Repair", + "versions.repairHelp": "Download anything missing again — keeps your worlds and settings.", + "versions.repairDone": "Minecraft {version} repaired", + + "quick.title": "Quick launch", + "quick.version": "Version", + "quick.launch": "Launch", + "quick.stop": "Stop", + "quick.noVersions": "Install a version to launch from here.", + "quick.vanillaOnly": "No adapter for this version — launches without live editing.", + "quick.projectTargets": "This project is made for {version}.", + "quick.projectTargetsMissing": "This project is made for {version}, which is not installed.", "project.title": "Project", "project.new": "New project", + "project.newSubtitle": "A project is one namespace and everything under it — your models, your textures and the settings for each one.", + "project.create": "Create the project", + "project.namePlaceholder": "e.g. Medieval set", + "project.nameRequired": "Give the project a name first", + "project.createdDone": "Created “{name}”", + "project.savedDone": "Project saved", + "project.deletedDone": "Deleted “{name}”", + "project.noEntriesHelp": "Add a block or an item and Ella writes a starter model and texture you can open straight in Blockbench.", + "project.noProjectHelp": "Projects keep your models, textures and settings together under one namespace.", "project.open": "Open project", "project.name": "Name", "project.namespace": "Namespace", "project.namespaceHelp": "Lowercase letters, digits and underscores. Used in resource paths.", - "project.targetVersions": "Target versions", + "project.targetVersion": "Minecraft version", + "project.targetVersionHelp": "The version this project is authored for. It is preselected when the project is opened, and launching another one asks first.", + "project.targetVersionNone": "Not bound yet — the next launch decides", + "project.targetVersionMissing": "{version} (not installed)", "project.entries": "Blocks and items", "project.none": "No project open", "project.close": "Close", @@ -77,10 +160,23 @@ "entry.idHelp": "Lowercase letters, digits and underscores.", "entry.registryName": "Registry name on export", "entry.translationKey": "Translation key on export", + "entry.previewRotate": "Drag to turn the model · double-click to reset", "entry.displayName": "Display name", "entry.kind": "Type", "entry.kind.block": "Block", "entry.kind.item": "Item", + "entry.kind.block.help": "Placed in the world. Has a hitbox, a hardness and a render layer.", + "entry.kind.item.help": "Held in the hand or the inventory. Simpler, and always available.", + "entry.displayNameOptional": "Optional", + "entry.nameRequired": "Give it a name first", + "entry.needsGame": "Launch the game first", + "entry.needsSlot": "Not bound to a slot — restart the game to bind it", + "entry.createdDone": "Created “{name}”", + "entry.deletedDone": "Removed “{id}”", + "entry.renamedDone": "Renamed “{from}” to “{to}”", + "entry.renamedTitleDone": "Now called “{name}”", + "entry.giveDone": "Added to your inventory", + "entry.placeDone": "Placed in front of you", "entry.delete": "Delete", "entry.deleteTitle": "Remove “{id}” from the project?", "entry.deleteExplain": "The entry is removed and its slot freed. Your model and texture files are kept unless you tick the box below.", @@ -92,6 +188,8 @@ "entry.place": "Place in front of player", "entry.slot": "Slot", "entry.unbound": "Not bound to a slot", + "entry.settings": "Settings", + "entry.create": "Create", "texture.title": "Textures", "texture.add": "Add texture", @@ -109,17 +207,37 @@ "texture.usedBy": "Used by: {faces}", "texture.unused": "Not used by any face.", "texture.orphanedFaces": "Removed “{key}”, but these faces still reference it and will fail to load: {faces}", + "texture.removedDone": "Removed the “{key}” texture", "model.source": "Model source", "model.source.json": "Vanilla JSON", "model.source.bbmodel": "Blockbench file", "model.source.obj": "OBJ mesh", "model.source.objUnavailable": "OBJ support is not implemented yet", + "model.parentTrap": "This model inherits from {parent}, which on Minecraft 1.8.x replaces its own {count} shape(s) with the parent's — so it renders as a plain cube in game.", + "model.parentRemoved": "Removed the {parent} parent — your own shapes now render.", + "compat.title": "Launch on a different version?", + "compat.subtitle": "“{project}” is made for {from}, and you are about to launch {to}.", + "compat.files": "In this project's files", + "compat.noIssues": "Nothing in this project needs changing for {to}.", + "compat.fixable": "Ella can fix it", + "compat.manual": "Needs a manual fix", + "compat.automatic": "The pack format and the slot files are regenerated for whichever version connects, so those need nothing from you.", + "compat.adopt": "Move this project to {to}", + "compat.adoptAndFix": "Move this project to {to} and rewrite {count} model file(s)", + "compat.oneOff": "Left unticked, this run is a one-off: the project stays on {from} and its files are not touched.", + "compat.launch": "Launch {to}", + "compat.issue.parentOverridesGeometry": "Its {parent} parent wins over the model's own geometry before 1.9 — it would render as the parent instead.", + "compat.issue.vanillaTextureFolder": "Refers to the vanilla texture {reference}, which on {to} is called {expected} — it would show the missing-texture checkerboard.", + "compat.note.notInstalled": "{version} is not installed.", + "compat.note.noAdapter": "No Ella adapter covers {version}, so live editing will not work there.", + "compat.note.plannedAdapter": "The adapter for {version} is planned but not built yet, so live editing will not work there.", + "compat.note.losesLiveEditing": "{from} supports live editing and {to} does not — edits will stop reaching the game.", + "compat.note.javaMissing": "{version} needs Java {java}, and no matching runtime was found.", "settings.group.appearance": "Appearance", "settings.group.physical": "Physical", "settings.group.interaction": "Interaction", - "settings.block.renderLayer": "Render layer", "settings.block.renderLayer.help": "How the block's faces are drawn. Anything other than Solid also needs Occludes neighbours turned off.", "settings.block.opaque": "Occludes neighbours", @@ -140,12 +258,10 @@ "settings.block.collision": "Collision", "settings.block.hitbox": "Hitbox", "settings.block.hitbox.help": "Bounds in model space, 0 to 16 on each axis.", - "settings.renderLayer.solid": "Solid", "settings.renderLayer.cutout": "Cutout", "settings.renderLayer.cutoutMipped": "Cutout (mipped)", "settings.renderLayer.translucent": "Translucent", - "settings.soundType.stone": "Stone", "settings.soundType.wood": "Wood", "settings.soundType.gravel": "Gravel", @@ -155,27 +271,39 @@ "settings.soundType.wool": "Wool", "settings.soundType.sand": "Sand", "settings.soundType.snow": "Snow", - "settings.block.rotation": "Rotation", "settings.block.rotation.help": "How the block orients itself when placed. Your model should face north; the game turns it.", "settings.rotation.none": "Fixed", "settings.rotation.horizontal": "Faces the player (4 ways)", "settings.rotation.all": "Faces the clicked side (6 ways)", - "settings.collision.full": "Full block", "settings.collision.none": "Walk through", "settings.collision.custom": "Custom box", - "settings.item.handheld": "Held like a tool", "settings.item.handheld.help": "Uses the handheld model parent, angled in the hand.", "settings.item.glint": "Enchantment glint", "settings.item.stackSize": "Stack size", "settings.item.rarity": "Rarity", - "settings.rarity.common": "Common", "settings.rarity.uncommon": "Uncommon", "settings.rarity.rare": "Rare", "settings.rarity.epic": "Epic", + "settings.title": "Settings", + "settings.subtitle": "Where Ella finds your tools, and how much room it reserves in game.", + "settings.language": "Language", + "settings.username": "In-game name", + "settings.usernameHelp": "The name Ella launches Minecraft under. Offline only — no account is involved.", + "settings.blockbench": "Blockbench", + "settings.blockbenchPath": "Blockbench path", + "settings.blockbenchPlaceholder": "Leave empty to detect it automatically", + "settings.blockbenchDetected": "Detected automatically at {path}", + "settings.blockbenchOk": "Ella will use this executable.", + "settings.blockbenchMissing": "Ella could not find Blockbench. Set the path here, or install it from blockbench.net.", + "settings.javaHelp": "Ella picks the right runtime per Minecraft version. Old versions need Java 8; recent ones need 17 or 21.", + "settings.javaNone": "No Java runtime was found, so no version can be launched. Install a JDK and reopen Ella.", + "settings.dataDirectory": "Data directory", + "settings.slotPool": "Slot pool size", + "settings.slotPoolHelp": "How many blocks and items are reserved at startup. Higher costs nothing but needs a restart to change.", "warning.transparentButOpaque": "This render layer draws transparency, but the block still occludes its neighbours, so it will look solid in game. Turn off Occludes neighbours.", "warning.nonOpaqueSolidLayer": "The block does not occlude neighbours but uses the Solid layer, so transparent pixels will render black. Switch to Cutout.", @@ -191,14 +319,23 @@ "game.reloading": "Reloading resources…", "game.reloaded": "Resources reloaded", "game.slotsUsed": "{used} of {total} slots used", + "game.slotsHelp": "Ella reserves a fixed pool of block and item slots when the game starts; each of your entries takes one.", "game.slotsFull": "All slots are in use. Restart the game to free them.", "blockbench.notFound": "Blockbench was not found", "blockbench.notFoundHelp": "Set the path to Blockbench in Settings.", + "blockbench.setPath": "Set its path", "blockbench.opening": "Opening Blockbench…", "blockbench.watching": "Watching for changes", "export.title": "Export", + "export.subtitle": "Turn what you have made into something you can hand to someone else.", + "export.running": "Exporting…", + "export.doneShort": "Resource pack exported", + "export.files": "files", + "export.blocked": "Fix the errors below, then export again.", + "export.needsEntries": "Add a block or an item first", + "export.planned": "Planned", "export.resourcePack": "Resource pack", "export.resourcePackHelp": "A zip you can drop straight into the resource pack folder.", "export.mod": "Forge mod", @@ -216,13 +353,7 @@ "plugin.reinstall": "Reinstall", "plugin.installed": "Installed", "plugin.outdated": "Update available", - - "settings.title": "Settings", - "settings.language": "Language", - "settings.blockbenchPath": "Blockbench path", - "settings.dataDirectory": "Data directory", - "settings.slotPool": "Slot pool size", - "settings.slotPoolHelp": "How many blocks and items are reserved at startup. Higher costs nothing but needs a restart to change.", + "plugin.installedDone": "Plugin installed — restart Blockbench to load it", "crash.title": "Minecraft {version} stopped unexpectedly", "crash.report": "Crash report", @@ -241,5 +372,10 @@ "common.close": "Close", "common.browse": "Browse…", "common.retry": "Retry", - "common.error": "Error" + "common.error": "Error", + "common.noChanges": "Nothing has changed", + "common.undo": "Undo", + "common.undone": "Change undone", + "common.dismiss": "Dismiss", + "common.saving": "Saving…" } diff --git a/launcher/locales/fr.json b/launcher/locales/fr.json index dbd382b..621ce33 100644 --- a/launcher/locales/fr.json +++ b/launcher/locales/fr.json @@ -2,10 +2,15 @@ "app.name": "Ella", "app.tagline": "Un atelier de test de modèles pour les auteurs Blockbench", + "nav.home": "Accueil", + "nav.needsProject": "Ouvre d'abord un projet", "nav.versions": "Versions", "nav.project": "Projet", "nav.editor": "Éditeur", "nav.logs": "Logs", + "nav.export": "Export", + "nav.settings": "Réglages", + "logs.filter": "Filtrer…", "logs.follow": "Suivre", "logs.clear": "Effacer", @@ -15,17 +20,71 @@ "logs.level.info": "Info et plus", "logs.level.warn": "Avertissements et erreurs", "logs.level.error": "Erreurs seulement", - "nav.export": "Export", - "nav.settings": "Réglages", + + "home.welcome": "Bienvenue dans Ella", + "home.welcomeText": "Modélise un bloc dans Blockbench, enregistre, et regarde-le changer dans le jeu en cours. Cinq étapes et tu es en live.", + "home.projectSubtitle": "Espace de noms {namespace}. Tout ce que tu crées atterrit dessous.", + "home.stat.live": "Édition live", + "home.stat.minecraft": "Minecraft", + "home.stat.blockbench": "Blockbench", + "home.live.on": "Live", + "home.live.off": "Inactive", + "home.found": "Trouvé", + "home.notFound": "Introuvable", + "home.noLiveVersion": "Aucune de tes versions installées ne gère l'édition live : le jeu se lancera mais ne reprendra jamais tes modifications.", + "home.noLiveVersionAction": "Voir celles qui le peuvent", + "home.quickActions": "Et maintenant ?", + "home.recent": "Tes blocs et objets", + "home.action.editor": "Ouvrir l'éditeur", + "home.action.editorText": "Ajuste l'aspect et le comportement d'un bloc pendant que le jeu tourne.", + "home.action.newText": "Ajoute un autre bloc ou objet au projet.", + "home.action.exportText": "Empaquette tout dans un zip de pack de ressources.", + "home.action.logsText": "Lis ce que disent le jeu et le launcher.", + "home.resume": "Reprendre où tu en étais", + "home.resumeHelp": "Ouvrir l’un d’eux remet ses blocs et objets dans l’éditeur et les recharge dans un jeu en cours.", + "home.newProjectText": "Démarrer un nouvel espace de noms à toi.", + + "guide.title": "Mise en route", + "guide.subtitle": "Ella a besoin de ces cinq points avant qu'une modification dans Blockbench puisse atteindre le jeu en cours. Chacun se vérifie tout seul — rien ici n'est une case à cocher.", + "guide.progress": "{done} sur {total}", + "guide.show": "Afficher la checklist", + "guide.hide": "Masquer la checklist", + "guide.version.title": "Installer une version de Minecraft", + "guide.version.why": "Ella télécharge et gère sa propre copie : rien de ce qu'elle fait ne touche à ton installation habituelle.", + "guide.version.action": "Choisir une version", + "guide.project.title": "Créer un projet", + "guide.project.why": "Un projet regroupe tes modèles, tes textures et leurs réglages, et leur donne l'espace de noms sous lequel le jeu les charge.", + "guide.project.action": "En créer un", + "guide.entry.title": "Ajouter un bloc ou un objet", + "guide.entry.why": "Ella lui écrit un modèle et une texture de départ, prêts à ouvrir dans Blockbench.", + "guide.entry.action": "En ajouter un", + "guide.blockbench.title": "Indiquer Blockbench à Ella", + "guide.blockbench.why": "Ella confie à Blockbench le fichier de modèle que le jeu lit vraiment : enregistrer, c'est mettre à jour — il n'y a ni import ni export entre les deux.", + "guide.blockbench.action": "Indiquer le chemin", + "guide.launch.title": "Lancer le jeu", + "guide.launch.why": "Le mod d'Ella se reconnecte au launcher au démarrage. Une fois connecté, chaque sauvegarde apparaît en jeu sans rechargement.", + "guide.launch.action": "Lancer", + + "progress.download": "Téléchargement", + "progress.install": "Installation", + "progress.export": "Export", + + "status.adapter": "Version, loader et adaptateur Ella en cours", "versions.title": "Version de Minecraft", + "versions.subtitle": "Ella gère ses propres installations, séparées de celles du launcher officiel.", + "versions.recommended": "Prêtes pour l'édition live", + "versions.recommendedHelp": "Ces versions disposent d'un adaptateur Ella construit : les modifications atteignent le jeu en cours. Toute autre version s'installe et se lance, mais en Minecraft ordinaire.", + "versions.refresh": "Actualiser", + "versions.installing": "Installation…", + "versions.installDone": "Minecraft {version} installée", + "versions.uninstallDone": "Minecraft {version} désinstallée", + "versions.noneInstalledHelp": "Choisis-en une dans la liste ci-dessous. La première installation télécharge quelques centaines de mégaoctets ; les suivantes en réutilisent l'essentiel.", + "versions.alreadyRunning": "Arrête d'abord le jeu en cours", + "versions.javaFoundHelp": "Un runtime Java correspondant a été trouvé sur cette machine.", + "versions.vanillaOnlyHelp": "Aucun adaptateur Ella ne couvre cette version : elle se lance en Minecraft ordinaire.", "versions.installed": "Installées", "versions.noneInstalled": "Aucune version installée. Installe-en une ci-dessous pour commencer.", - "quick.version": "Version", - "quick.launch": "Lancer", - "quick.stop": "Arrêter", - "quick.noVersions": "Installe une version pour la lancer d'ici.", - "quick.vanillaOnly": "Pas d'adaptateur pour cette version — lancement sans édition live.", "versions.available": "Disponibles", "versions.showSnapshots": "Afficher les snapshots", "versions.install": "Installer", @@ -48,14 +107,38 @@ "versions.javaRequired": "Nécessite Java {java}", "versions.javaMissing": "Java {java} est introuvable sur cette machine", "versions.javaFound": "Utilise Java {version}", + "versions.repair": "Réparer", + "versions.repairHelp": "Retélécharge ce qui manque — conserve tes mondes et tes réglages.", + "versions.repairDone": "Minecraft {version} réparée", + + "quick.title": "Lancement rapide", + "quick.version": "Version", + "quick.launch": "Lancer", + "quick.stop": "Arrêter", + "quick.noVersions": "Installe une version pour la lancer d'ici.", + "quick.vanillaOnly": "Pas d'adaptateur pour cette version — lancement sans édition live.", + "quick.projectTargets": "Ce projet est fait pour la {version}.", + "quick.projectTargetsMissing": "Ce projet est fait pour la {version}, qui n'est pas installée.", "project.title": "Projet", "project.new": "Nouveau projet", + "project.newSubtitle": "Un projet, c'est un espace de noms et tout ce qu'il contient — tes modèles, tes textures et les réglages de chacun.", + "project.create": "Créer le projet", + "project.namePlaceholder": "ex. Set médiéval", + "project.nameRequired": "Donne d'abord un nom au projet", + "project.createdDone": "« {name} » créé", + "project.savedDone": "Projet enregistré", + "project.deletedDone": "« {name} » supprimé", + "project.noEntriesHelp": "Ajoute un bloc ou un objet et Ella lui écrit un modèle et une texture de départ, ouvrables directement dans Blockbench.", + "project.noProjectHelp": "Un projet regroupe tes modèles, tes textures et leurs réglages sous un même espace de noms.", "project.open": "Ouvrir un projet", "project.name": "Nom", "project.namespace": "Espace de noms", "project.namespaceHelp": "Minuscules, chiffres et tirets bas. Utilisé dans les chemins de ressources.", - "project.targetVersions": "Versions ciblées", + "project.targetVersion": "Version de Minecraft", + "project.targetVersionHelp": "La version pour laquelle ce projet est conçu. Elle est présélectionnée à l'ouverture du projet, et lancer une autre version demande confirmation.", + "project.targetVersionNone": "Pas encore liée — le prochain lancement décidera", + "project.targetVersionMissing": "{version} (non installée)", "project.entries": "Blocs et objets", "project.none": "Aucun projet ouvert", "project.close": "Fermer", @@ -77,10 +160,23 @@ "entry.idHelp": "Minuscules, chiffres et tirets bas.", "entry.registryName": "Nom de registre à l'export", "entry.translationKey": "Clé de traduction à l'export", + "entry.previewRotate": "Glissez pour tourner le modèle · double-clic pour réinitialiser", "entry.displayName": "Nom affiché", "entry.kind": "Type", "entry.kind.block": "Bloc", "entry.kind.item": "Objet", + "entry.kind.block.help": "Se pose dans le monde. A une boîte de collision, une dureté et une couche de rendu.", + "entry.kind.item.help": "Se tient en main ou dans l'inventaire. Plus simple, et toujours disponible.", + "entry.displayNameOptional": "Optionnel", + "entry.nameRequired": "Donne-lui d'abord un nom", + "entry.needsGame": "Lance d'abord le jeu", + "entry.needsSlot": "Associé à aucun emplacement — relance le jeu pour l'associer", + "entry.createdDone": "« {name} » créé", + "entry.deletedDone": "« {id} » retiré", + "entry.renamedDone": "« {from} » renommé en « {to} »", + "entry.renamedTitleDone": "S'appelle maintenant « {name} »", + "entry.giveDone": "Ajouté à ton inventaire", + "entry.placeDone": "Posé devant toi", "entry.delete": "Supprimer", "entry.deleteTitle": "Retirer « {id} » du projet ?", "entry.deleteExplain": "L'entrée est retirée et son emplacement libéré. Tes fichiers de modèle et de texture sont conservés, sauf si tu coches la case ci-dessous.", @@ -92,6 +188,8 @@ "entry.place": "Poser devant le joueur", "entry.slot": "Emplacement", "entry.unbound": "Associé à aucun emplacement", + "entry.settings": "Réglages", + "entry.create": "Créer", "texture.title": "Textures", "texture.add": "Ajouter une texture", @@ -109,17 +207,37 @@ "texture.usedBy": "Utilisée par : {faces}", "texture.unused": "Utilisée par aucune face.", "texture.orphanedFaces": "« {key} » retirée, mais ces faces y font encore référence et ne se chargeront pas : {faces}", + "texture.removedDone": "Texture « {key} » retirée", "model.source": "Source du modèle", "model.source.json": "JSON vanilla", "model.source.bbmodel": "Fichier Blockbench", "model.source.obj": "Maillage OBJ", "model.source.objUnavailable": "La prise en charge de l'OBJ n'est pas encore implémentée", + "model.parentTrap": "Ce modèle hérite de {parent}, ce qui sur Minecraft 1.8.x remplace ses {count} forme(s) par celles du parent — il s'affiche donc comme un cube ordinaire en jeu.", + "model.parentRemoved": "Parent {parent} retiré — tes propres formes s'affichent maintenant.", + "compat.title": "Lancer sur une autre version ?", + "compat.subtitle": "« {project} » est fait pour la {from}, et tu t'apprêtes à lancer la {to}.", + "compat.files": "Dans les fichiers du projet", + "compat.noIssues": "Rien dans ce projet n'a besoin d'être modifié pour la {to}.", + "compat.fixable": "Ella peut corriger", + "compat.manual": "À corriger à la main", + "compat.automatic": "Le format du pack et les fichiers de slots sont régénérés pour la version qui se connecte : rien à faire de ce côté.", + "compat.adopt": "Passer ce projet en {to}", + "compat.adoptAndFix": "Passer ce projet en {to} et réécrire {count} fichier(s) de modèle", + "compat.oneOff": "Sans cette case, ce lancement est ponctuel : le projet reste en {from} et ses fichiers ne sont pas touchés.", + "compat.launch": "Lancer la {to}", + "compat.issue.parentOverridesGeometry": "Son parent {parent} l'emporte sur la géométrie du modèle avant la 1.9 — il s'afficherait comme le parent.", + "compat.issue.vanillaTextureFolder": "Référence la texture vanilla {reference}, qui s'appelle {expected} en {to} — elle afficherait le damier de texture manquante.", + "compat.note.notInstalled": "La {version} n'est pas installée.", + "compat.note.noAdapter": "Aucun adaptateur Ella ne couvre la {version} : pas d'édition en direct.", + "compat.note.plannedAdapter": "L'adaptateur pour la {version} est prévu mais pas encore compilé : pas d'édition en direct.", + "compat.note.losesLiveEditing": "La {from} permet l'édition en direct, pas la {to} — les modifications n'arriveront plus au jeu.", + "compat.note.javaMissing": "La {version} demande Java {java}, introuvable sur cette machine.", "settings.group.appearance": "Apparence", "settings.group.physical": "Physique", "settings.group.interaction": "Interaction", - "settings.block.renderLayer": "Couche de rendu", "settings.block.renderLayer.help": "Façon dont les faces du bloc sont dessinées. Toute valeur autre que Opaque nécessite aussi de désactiver Masque les voisins.", "settings.block.opaque": "Masque les voisins", @@ -140,12 +258,10 @@ "settings.block.collision": "Collision", "settings.block.hitbox": "Boîte de collision", "settings.block.hitbox.help": "Limites dans l'espace du modèle, de 0 à 16 sur chaque axe.", - "settings.renderLayer.solid": "Opaque", "settings.renderLayer.cutout": "Découpe", "settings.renderLayer.cutoutMipped": "Découpe (mipmap)", "settings.renderLayer.translucent": "Translucide", - "settings.soundType.stone": "Pierre", "settings.soundType.wood": "Bois", "settings.soundType.gravel": "Gravier", @@ -155,27 +271,39 @@ "settings.soundType.wool": "Laine", "settings.soundType.sand": "Sable", "settings.soundType.snow": "Neige", - "settings.block.rotation": "Rotation", "settings.block.rotation.help": "Orientation du bloc à la pose. Ton modèle doit être orienté vers le nord ; le jeu le tourne.", "settings.rotation.none": "Fixe", "settings.rotation.horizontal": "Face au joueur (4 sens)", "settings.rotation.all": "Face au côté cliqué (6 sens)", - "settings.collision.full": "Bloc plein", "settings.collision.none": "Traversable", "settings.collision.custom": "Boîte personnalisée", - "settings.item.handheld": "Tenu comme un outil", "settings.item.handheld.help": "Utilise le modèle parent handheld, incliné dans la main.", "settings.item.glint": "Reflet d'enchantement", "settings.item.stackSize": "Taille de pile", "settings.item.rarity": "Rareté", - "settings.rarity.common": "Commun", "settings.rarity.uncommon": "Peu commun", "settings.rarity.rare": "Rare", "settings.rarity.epic": "Épique", + "settings.title": "Réglages", + "settings.subtitle": "Où Ella trouve tes outils, et combien de place elle réserve en jeu.", + "settings.language": "Langue", + "settings.username": "Pseudo en jeu", + "settings.usernameHelp": "Le nom sous lequel Ella lance Minecraft. Hors ligne uniquement — aucun compte n'est impliqué.", + "settings.blockbench": "Blockbench", + "settings.blockbenchPath": "Chemin de Blockbench", + "settings.blockbenchPlaceholder": "Laisse vide pour une détection automatique", + "settings.blockbenchDetected": "Détecté automatiquement dans {path}", + "settings.blockbenchOk": "Ella utilisera cet exécutable.", + "settings.blockbenchMissing": "Ella n'a pas trouvé Blockbench. Indique le chemin ici, ou installe-le depuis blockbench.net.", + "settings.javaHelp": "Ella choisit le bon runtime selon la version de Minecraft. Les anciennes versions demandent Java 8 ; les récentes 17 ou 21.", + "settings.javaNone": "Aucun runtime Java trouvé : aucune version ne peut être lancée. Installe un JDK et rouvre Ella.", + "settings.dataDirectory": "Dossier de données", + "settings.slotPool": "Taille du pool d'emplacements", + "settings.slotPoolHelp": "Nombre de blocs et d'objets réservés au démarrage. Une valeur élevée ne coûte rien, mais la modifier nécessite un redémarrage.", "warning.transparentButOpaque": "Cette couche de rendu gère la transparence, mais le bloc masque toujours ses voisins : il paraîtra opaque en jeu. Désactive Masque les voisins.", "warning.nonOpaqueSolidLayer": "Le bloc ne masque pas ses voisins mais utilise la couche Opaque : les pixels transparents s'afficheront en noir. Passe en Découpe.", @@ -191,14 +319,23 @@ "game.reloading": "Rechargement des ressources…", "game.reloaded": "Ressources rechargées", "game.slotsUsed": "{used} emplacements sur {total} utilisés", + "game.slotsHelp": "Ella réserve au démarrage du jeu un pool fixe d'emplacements de blocs et d'objets ; chacune de tes entrées en occupe un.", "game.slotsFull": "Tous les emplacements sont occupés. Relance le jeu pour en libérer.", "blockbench.notFound": "Blockbench est introuvable", "blockbench.notFoundHelp": "Indique le chemin vers Blockbench dans les Réglages.", + "blockbench.setPath": "Indiquer son chemin", "blockbench.opening": "Ouverture de Blockbench…", "blockbench.watching": "Surveillance des modifications", "export.title": "Export", + "export.subtitle": "Transforme ce que tu as créé en quelque chose que tu peux transmettre.", + "export.running": "Export en cours…", + "export.doneShort": "Pack de ressources exporté", + "export.files": "fichiers", + "export.blocked": "Corrige les erreurs ci-dessous, puis relance l'export.", + "export.needsEntries": "Ajoute d'abord un bloc ou un objet", + "export.planned": "Prévu", "export.resourcePack": "Pack de ressources", "export.resourcePackHelp": "Un zip à déposer directement dans le dossier des packs de ressources.", "export.mod": "Mod Forge", @@ -216,13 +353,7 @@ "plugin.reinstall": "Réinstaller", "plugin.installed": "Installé", "plugin.outdated": "Mise à jour disponible", - - "settings.title": "Réglages", - "settings.language": "Langue", - "settings.blockbenchPath": "Chemin de Blockbench", - "settings.dataDirectory": "Dossier de données", - "settings.slotPool": "Taille du pool d'emplacements", - "settings.slotPoolHelp": "Nombre de blocs et d'objets réservés au démarrage. Une valeur élevée ne coûte rien, mais la modifier nécessite un redémarrage.", + "plugin.installedDone": "Plugin installé — relance Blockbench pour le charger", "crash.title": "Minecraft {version} s'est arrêté de façon inattendue", "crash.report": "Rapport de crash", @@ -241,5 +372,10 @@ "common.close": "Fermer", "common.browse": "Parcourir…", "common.retry": "Réessayer", - "common.error": "Erreur" + "common.error": "Erreur", + "common.noChanges": "Rien n'a changé", + "common.undo": "Annuler", + "common.undone": "Modification annulée", + "common.dismiss": "Fermer", + "common.saving": "Enregistrement…" } diff --git a/launcher/package-lock.json b/launcher/package-lock.json index 3032fd0..45de550 100644 --- a/launcher/package-lock.json +++ b/launcher/package-lock.json @@ -1,12 +1,12 @@ { "name": "ella-launcher", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ella-launcher", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16" diff --git a/launcher/package.json b/launcher/package.json index 39330a7..f618853 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "ella-launcher", - "version": "0.1.0", + "version": "0.2.0", "description": "Ella — a model-testing workbench for Blockbench authors", "main": "./out/main/index.js", "author": "Ella", diff --git a/launcher/src/main/blockbench.ts b/launcher/src/main/blockbench.ts index 5826fc8..e241b33 100644 --- a/launcher/src/main/blockbench.ts +++ b/launcher/src/main/blockbench.ts @@ -63,7 +63,18 @@ export class ModelWatcher extends EventEmitter { * because editors commonly save by writing a temporary file and renaming it over the * target, which breaks a watch bound to the original inode. */ - async watchDirectory(directory: string): Promise { + async watchDirectory( + directory: string, + options: { + /** + * Paths to ignore, relative to `directory` and always with `/` separators. + * + * Needed because the watched tree contains generated output as well as the author's + * files. Without it Ella hears its own writes and reacts to them. + */ + ignore?: (relativePath: string) => boolean; + } = {}, + ): Promise { if (this.watchers.has(directory)) return; const exists = await stat(directory).then((s) => s.isDirectory(), () => false); @@ -76,6 +87,7 @@ export class ModelWatcher extends EventEmitter { if (!/\.(json|png)$/i.test(name)) return; // Ignore the temporary files atomic saves leave behind. if (name.endsWith('.tmp') || name.endsWith('.part')) return; + if (options.ignore?.(name.split(path.sep).join('/'))) return; this.pending.add(path.join(directory, name)); this.schedule(); diff --git a/launcher/src/main/diagnostics.ts b/launcher/src/main/diagnostics.ts index 95beb46..974a9f2 100644 --- a/launcher/src/main/diagnostics.ts +++ b/launcher/src/main/diagnostics.ts @@ -16,7 +16,7 @@ import { instanceDir, instanceModsDir } from './paths.ts'; import { findForgeVersionId } from './minecraft/forge.ts'; import { selectJavaFor } from './java-runtime.ts'; import { adapterCoverageFor, requiredJavaVersion } from '../shared/version.ts'; -import { LAUNCHER_VERSION } from './minecraft/launch.ts'; +import { APP_VERSION } from '../shared/app.ts'; /** Crash reports run long; keep enough to diagnose without producing an unusable wall. */ const MAX_CRASH_REPORT_LINES = 120; @@ -180,7 +180,7 @@ export async function collectCrashDiagnostics( context.javaUsed ?? (await selectJavaFor(versionId).catch(() => null)); const environment: Record = { - 'Ella': LAUNCHER_VERSION, + 'Ella': APP_VERSION, 'Minecraft': versionId, 'Forge': forgeVersionId ?? 'not installed', 'Launched': context.launchedVersionId ?? versionId, diff --git a/launcher/src/main/index.ts b/launcher/src/main/index.ts index d7b1051..1d0ec1f 100644 --- a/launcher/src/main/index.ts +++ b/launcher/src/main/index.ts @@ -8,7 +8,9 @@ import { fileURLToPath } from 'node:url'; import { CHANNELS, EVENTS, type Result } from '../shared/ipc.ts'; import { setDataRoot, ensureLayout, instanceDir } from './paths.ts'; import { formatDiagnostics, type CrashDiagnostics } from './diagnostics.ts'; -import { loadConfig, saveConfig } from './config.ts'; +import { loadConfig, saveConfig, resolveBlockbenchPath } from './config.ts'; +import { createSplash, MIN_SPLASH_MS } from './splash.ts'; +import { translate } from '../shared/i18n.ts'; import { discoverJavaRuntimes } from './java-runtime.ts'; import { listVersions, @@ -33,11 +35,17 @@ import { measureProject, updateProjectInfo, readEntryPreviews, + removeModelParent, + restoreEntry, + writeModelFile, } from './project.ts'; +import { UndoRegistry } from './undo.ts'; +import { planVersionChange, applyVersionChange } from './version-change.ts'; import { listTextures, addTexture, removeTexture, + restoreTexture, setParticleTexture, importTextureFor, } from './textures.ts'; @@ -48,6 +56,7 @@ const dirname = path.dirname(fileURLToPath(import.meta.url)); let window: BrowserWindow | null = null; const session = new Session(); +const undoable = new UndoRegistry(); /** Wraps a handler so IPC never rejects: the renderer always receives a Result. */ function handle(channel: string, handler: (...args: never[]) => Promise | T): void { @@ -83,6 +92,21 @@ function requireProject(): { project: NonNullable; root: str return { project: session.project, root: session.projectRoot }; } +/** + * Announces a change that can still be taken back. + * + * The inverse re-reads the open project rather than capturing it: seconds pass between the + * offer and the click, and applying an undo to the project as it was would quietly discard + * anything done in between. + */ +function offerUndo( + messageKey: string, + values: Record, + inverse: () => Promise, +): void { + send(EVENTS.undo, undoable.offer(messageKey, values, inverse)); +} + function findEntry(id: string) { const { project } = requireProject(); const entry = project.entries.find((candidate) => candidate.id === id); @@ -194,6 +218,10 @@ function registerHandlers(): void { }); handle(CHANNELS.projectsOpen, async (root: never) => { + // Every pending inverse names files and entries in the project being left. Applied to + // the next one they would restore something into a project it never belonged to. + undoable.clear(); + const project = await session.openProject(root); await saveConfig({ lastProject: root }); return project; @@ -212,7 +240,10 @@ function registerHandlers(): void { handle(CHANNELS.projectsDelete, async (root: never) => { // Close first: deleting the directory a watcher is bound to leaves the session // pointing at files that no longer exist. - if (session.projectRoot === root) session.closeProject(); + if (session.projectRoot === root) { + undoable.clear(); + session.closeProject(); + } await deleteProject(root); const config = await loadConfig(); @@ -222,6 +253,7 @@ function registerHandlers(): void { }); handle(CHANNELS.projectsClose, () => { + undoable.clear(); session.closeProject(); }); @@ -234,6 +266,33 @@ function registerHandlers(): void { return updated; }); + handle(CHANNELS.projectsPlanVersionChange, (versionId: never) => { + const { project, root } = requireProject(); + return planVersionChange(root, project, versionId); + }); + + handle(CHANNELS.projectsApplyVersionChange, async (versionId: never, migrate: never) => { + const { project, root } = requireProject(); + const result = await applyVersionChange(root, project, versionId, { migrate }); + + // Rewritten models are the files the game reads, so the session has to adopt the new + // project and push: skipping this would leave the running game on the old geometry + // until something else happened to trigger a reload. + await session.setProject(result.project); + + if (result.migrated.length > 0) { + send(EVENTS.log, { + level: 'info', + source: 'ella', + message: + `Adapted ${result.migrated.length} model(s) to Minecraft ${versionId}: ` + + result.migrated.join(', '), + }); + } + + return result; + }); + handle(CHANNELS.entriesCreate, async (options: never) => { const { project, root } = requireProject(); const { project: updated, entry } = await createEntry(root, project, options); @@ -243,8 +302,22 @@ function registerHandlers(): void { handle(CHANNELS.entriesUpdate, async (id: never, patch: never) => { const { project, root } = requireProject(); + const previous = findEntry(id).displayName; const { project: updated, entry } = await updateEntry(root, project, id, patch); await session.setProject(updated); + + // Only the display name. A slot or settings change arrives from a control that already + // shows its own value, so putting it back is moving that control back. + if ((patch as { displayName?: unknown }).displayName) { + offerUndo('entry.renamedTitleDone', { name: entry.displayName.en }, async () => { + const current = requireProject(); + const reverted = await updateEntry(current.root, current.project, entry.id, { + displayName: previous, + }); + await session.setProject(reverted.project); + }); + } + return entry; }); @@ -252,13 +325,32 @@ function registerHandlers(): void { const { project, root } = requireProject(); const { project: updated, entry } = await renameEntry(root, project, id, newId); await session.setProject(updated); + + // renameEntry is a no-op when the id has not changed, and an undo for nothing would be + // a notification for nothing. + if (entry.id !== id) { + offerUndo('entry.renamedDone', { from: id, to: entry.id }, async () => { + const current = requireProject(); + const reverted = await renameEntry(current.root, current.project, entry.id, id); + await session.setProject(reverted.project); + }); + } + return entry; }); handle(CHANNELS.entriesDelete, async (id: never, deleteFiles: never) => { const { project, root } = requireProject(); - const updated = await deleteEntry(root, project, id, { deleteFiles }); + const { project: updated, entry, index } = await deleteEntry(root, project, id, { + deleteFiles, + }); await session.setProject(updated); + + offerUndo('entry.deletedDone', { id }, async () => { + const current = requireProject(); + const restored = await restoreEntry(current.root, current.project, entry, index); + await session.setProject(restored.project); + }); }); handle(CHANNELS.entriesPatchLive, async (id: never, settings: never) => { @@ -266,8 +358,11 @@ function registerHandlers(): void { // Persist first: the on-disk project is the source of truth even if the game is // not running or silently ignores a key. const { project, root } = requireProject(); - const { project: updated } = await updateEntry(root, project, id, { settings }); - await session.setProject(updated, { push: false }); + const { project: updated, entry: patched } = await updateEntry(root, project, id, { settings }); + // Settings cannot affect any slot but this entry's own, so only that one is rewritten. + // This runs once per slider tick — a full namespace rebuild here is what made the + // editor lag behind the control the user was dragging. + await session.setEntry(updated, patched); return session.patchEntrySettings({ ...entry, settings }, settings); }); @@ -307,8 +402,17 @@ function registerHandlers(): void { handle(CHANNELS.entriesRemoveTexture, async (id: never, key: never, deleteFile: never) => { const { project, root } = requireProject(); - const result = await removeTexture(root, project, findEntry(id), key, { deleteFile }); + const { removed, ...result } = await removeTexture(root, project, findEntry(id), key, { + deleteFile, + }); await session.pushAll(); + + offerUndo('texture.removedDone', { key }, async () => { + const current = requireProject(); + await restoreTexture(current.root, current.project, findEntry(id), removed); + await session.pushAll(); + }); + return result; }); @@ -324,6 +428,25 @@ function registerHandlers(): void { return readEntryPreviews(root, project); }); + handle(CHANNELS.entriesRemoveModelParent, async (id: never) => { + const { root } = requireProject(); + const removed = await removeModelParent(root, findEntry(id)); + if (removed === null) return null; + + // The slot redirect points at this file, so what the game loads changes with it. + await session.pushAll(); + + // Ella rewrote a file the author owns, so the way back is the file as it was — not a + // re-derived version of it, which would also undo whatever Blockbench formatted. + offerUndo('model.parentRemoved', { parent: removed.parent }, async () => { + const current = requireProject(); + await writeModelFile(current.root, findEntry(id), removed.original); + await session.pushAll(); + }); + + return removed.parent; + }); + handle(CHANNELS.entriesRevealTexture, () => { const { project, root } = requireProject(); shell.showItemInFolder( @@ -339,9 +462,24 @@ function registerHandlers(): void { await session.placeEntry(findEntry(id)); }); + handle(CHANNELS.undoRun, (token: never) => undoable.run(token)); + handle(CHANNELS.gameLaunch, async (versionId: never) => { await session.launch(versionId); await saveConfig({ lastVersion: versionId }); + + // A project with no version yet adopts the first one it is launched on. Asking instead + // would be a dialog whose only answer is the version already being launched, and it + // means every project made before this existed binds itself on its next run. + if (session.project && session.projectRoot && session.project.targetVersion === null) { + const { project } = await applyVersionChange( + session.projectRoot, + session.project, + versionId, + { migrate: false }, + ); + await session.setProject(project, { push: false }); + } }); handle(CHANNELS.gameStop, () => { @@ -372,6 +510,7 @@ function registerHandlers(): void { return defaultExportName(project); }); + handleRaw(CHANNELS.blockbenchResolve, () => resolveBlockbenchPath()); handleRaw(CHANNELS.blockbenchPluginStatus, () => pluginStatus()); handle(CHANNELS.blockbenchInstallPlugin, () => installPlugin()); @@ -450,7 +589,11 @@ function sessionStateDto() { // Lifecycle // --------------------------------------------------------------------------- -function createWindow(): void { +/** + * @param splash Closed once the main window can paint, or null when there is none. + * @param shownAt When the splash appeared, so it can be held for its minimum. + */ +function createWindow(splash: BrowserWindow | null = null, shownAt = 0): void { window = new BrowserWindow({ width: 1280, height: 840, @@ -458,7 +601,9 @@ function createWindow(): void { minHeight: 640, show: false, autoHideMenuBar: true, - backgroundColor: '#16161c', + // Matches --bg, so the frame that shows before the renderer paints is not a flash of + // a different colour. + backgroundColor: '#0f0f14', webPreferences: { preload: path.join(dirname, '../preload/index.mjs'), sandbox: false, @@ -467,7 +612,25 @@ function createWindow(): void { }, }); - window.once('ready-to-show', () => window?.show()); + const closeSplash = (): void => { + if (splash && !splash.isDestroyed()) splash.close(); + }; + + window.once('ready-to-show', () => { + const remaining = Math.max(0, MIN_SPLASH_MS - (Date.now() - shownAt)); + setTimeout(() => { + // The main window comes up first. Closing the splash first would leave a frame of + // bare desktop where the app should be. + window?.show(); + closeSplash(); + }, remaining); + }); + + // The splash is always-on-top and has no close button, so it must never be able to + // outlive a main window that failed to paint. + const failsafe = setTimeout(closeSplash, 20_000); + window.once('closed', () => clearTimeout(failsafe)); + window.webContents.once('did-fail-load', closeSplash); // Anything that is not the app itself belongs in the user's browser. window.webContents.setWindowOpenHandler(({ url }) => { @@ -485,6 +648,13 @@ function createWindow(): void { void app.whenReady().then(async () => { setDataRoot(app.getPath('userData')); + + // Up before anything slow runs. Reopening the last project regenerates its pack, which + // is most of the wait between clicking the icon and seeing a window. + const startupConfig = await loadConfig(); + const splash = createSplash(translate(startupConfig.locale, 'app.tagline')); + const splashShownAt = Date.now(); + await ensureLayout(); registerHandlers(); @@ -511,14 +681,13 @@ void app.whenReady().then(async () => { } // Reopen whatever was last in use, so the app starts where the user left off. - const config = await loadConfig(); - if (config.lastProject) { - await session.openProject(config.lastProject).catch(() => { + if (startupConfig.lastProject) { + await session.openProject(startupConfig.lastProject).catch(() => { // A project that was moved or deleted simply does not reopen. }); } - createWindow(); + createWindow(splash, splashShownAt); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); diff --git a/launcher/src/main/minecraft/forge.ts b/launcher/src/main/minecraft/forge.ts index 3394faa..a06c772 100644 --- a/launcher/src/main/minecraft/forge.ts +++ b/launcher/src/main/minecraft/forge.ts @@ -5,14 +5,25 @@ * That matters most from 1.13 onwards, where installation is not just unpacking files: * the installer runs binary patch and deobfuscation processors, and reproducing those * would mean tracking changes to a toolchain that is not ours. + * + * The installers of 2015 and 2016 have no headless client mode at all — `--installClient` + * was added later, and passing it to an older one aborts with "not a recognized option". + * Those builds also predate the processors, so their install genuinely is just unpacking + * files, and {@link installLegacyForge} does it directly. Which path applies is read off + * the installer's own `install_profile.json` rather than guessed from a version number: + * the old generation carries a `versionInfo` block, the new one carries `processors`. */ import { spawn } from 'node:child_process'; import { readFile, writeFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; -import { fetchJson, downloadFile } from './download.ts'; -import { ensureDir, getDataRoot, versionsDir, cacheDir } from '../paths.ts'; +import AdmZip from 'adm-zip'; +import { fetchJson, downloadFile, downloadAll } from './download.ts'; +import { ensureDir, getDataRoot, versionsDir, versionDir, librariesDir, cacheDir } from '../paths.ts'; import { selectJavaFor } from '../java-runtime.ts'; +import { mavenToPath, resolveLibraries } from './libraries.ts'; +import { libraryDownloadTasks } from './install.ts'; +import type { VersionJson } from './types.ts'; import { compareVersions } from '../../shared/version.ts'; const PROMOTIONS_URL = @@ -145,6 +156,107 @@ export interface ForgeInstallResult { versionId: string; } +/** + * The pre-2018 installer layout: a complete version document plus one jar to file away. + * + * `versionInfo` is written verbatim as the version json — it already carries + * `inheritsFrom`, so the vanilla document supplies everything it omits. `install.filePath` + * names the universal jar inside the installer, and `install.path` says where in + * `libraries/` it belongs. + */ +interface LegacyInstallProfile { + install: { filePath: string; path: string }; + versionInfo: { id: string } & Record; +} + +/** + * Whether an installer belongs to the generation Ella has to unpack itself. + * + * Exported for testing: getting this wrong in either direction is silent. Answering yes + * for a modern installer would skip the processors and produce a version that launches + * into a crash; answering no for an old one puts back the "not a recognized option" + * failure this exists to fix. + */ +export const isLegacyProfile = (value: unknown): value is LegacyInstallProfile => { + const profile = value as LegacyInstallProfile | null; + return ( + typeof profile === 'object' && + profile !== null && + typeof profile.install?.filePath === 'string' && + typeof profile.install?.path === 'string' && + typeof profile.versionInfo?.id === 'string' + ); +}; + +/** Reads `install_profile.json` out of an installer jar, or null when it has none. */ +function readInstallProfile(installerPath: string): unknown { + try { + const entry = new AdmZip(installerPath).getEntry('install_profile.json'); + return entry ? JSON.parse(entry.getData().toString('utf8')) : null; + } catch { + // A profile that cannot be read is not a legacy one; fall through to the installer, + // whose own error message will be more useful than anything invented here. + return null; + } +} + +/** + * Installs a pre-2018 Forge build by unpacking it, with no Java process involved. + * + * Three things make the version launchable: the version json, the universal jar in the + * place its own library entry points at, and every other library the json declares. + * + * That third step is not optional and is easy to overlook, because the official installer + * does it invisibly. Without it FML dies before the game window ever opens with + * `NoClassDefFoundError: org/objectweb/asm/ClassVisitor` — ASM is listed in the Forge json + * with no download url at all, so nothing else in the pipeline would ever fetch it. + * + * @returns the version id the game should be launched with + */ +async function installLegacyForge( + installerPath: string, + profile: LegacyInstallProfile, + onLog?: (line: string) => void, +): Promise { + const id = profile.versionInfo.id; + + const directory = await ensureDir(versionDir(id)); + await writeFile( + path.join(directory, `${id}.json`), + JSON.stringify(profile.versionInfo, null, 2), + 'utf8', + ); + + const zip = new AdmZip(installerPath); + const universal = zip.getEntry(profile.install.filePath); + if (!universal) { + throw new Error( + `The Forge installer is missing ${profile.install.filePath}, so ${id} cannot be installed`, + ); + } + + const target = path.join(librariesDir(), mavenToPath(profile.install.path)); + await ensureDir(path.dirname(target)); + await writeFile(target, universal.getData()); + + // Only the libraries this version file adds: the vanilla ones were fetched when the + // base version was installed, and anything already on disk is skipped by the downloader. + const version = profile.versionInfo as unknown as VersionJson; + const { classpath, natives } = resolveLibraries(version, librariesDir()); + const tasks = await libraryDownloadTasks([...classpath, ...natives]); + + const { failures } = await downloadAll(tasks, { concurrency: 8 }); + if (failures.length > 0) { + throw new Error( + `${failures.length} of ${tasks.length} Forge libraries could not be downloaded — ` + + `the first was ${failures[0].url}`, + ); + } + + onLog?.(`Fetched ${tasks.length} Forge libraries for ${id}`); + return id; +} + export async function installForge( mcVersion: string, options: { channel?: 'recommended' | 'latest'; onLog?: (line: string) => void } = {}, @@ -161,6 +273,17 @@ export async function installForge( ); await downloadFile({ url: build.installerUrl, destination: installerPath }); + // Old builds have no headless client install, so Ella does it itself. Checked against + // the installer's own profile rather than the Minecraft version: the change came with + // an installer generation, not with a game release, and 1.12.2 sits on the new side of + // it while 1.8.9 sits on the old one. + const profile = readInstallProfile(installerPath); + if (isLegacyProfile(profile)) { + options.onLog?.(`Installing Forge ${build.forgeVersion} directly (installer predates --installClient)`); + const versionId = await installLegacyForge(installerPath, profile, options.onLog); + return { build, versionId }; + } + const before = new Set( (await readdir(versionsDir(), { withFileTypes: true }).catch(() => [])) .filter((entry) => entry.isDirectory()) diff --git a/launcher/src/main/minecraft/install.ts b/launcher/src/main/minecraft/install.ts index 819fcf9..c2530b9 100644 --- a/launcher/src/main/minecraft/install.ts +++ b/launcher/src/main/minecraft/install.ts @@ -15,7 +15,7 @@ import { type ProgressCallback, type DownloadError, } from './download.ts'; -import { resolveLibraries } from './libraries.ts'; +import { resolveLibraries, type ResolvedLibrary } from './libraries.ts'; import { resolveVersionJson } from './manifest.ts'; import { assetIndexesDir, @@ -49,29 +49,25 @@ export interface InstallOptions { const assetObjectPath = (hash: string): string => path.join(assetObjectsDir(), hash.slice(0, 2), hash); -export async function installVersion( - id: string, - options: InstallOptions = {}, -): Promise { - const version = await resolveVersionJson(id); +/** + * Turns resolved libraries into download tasks. + * + * A library entry comes in three shapes and the difference is not cosmetic. Modern files + * give a direct url with a checksum. Forge's give a repository base and expect the path + * to be derived from the Maven coordinate. The oldest give neither and assume the + * launcher knows Mojang's own repository — that last group is why 1.8.x needs this at + * all, since `asm-all` and `trove4j` are listed with no url whatsoever. + * + * Exported because the legacy Forge install needs exactly this. Those installers leave + * every library to the launcher, and a second copy of the three-shape logic would drift + * from this one. + */ +export async function libraryDownloadTasks( + libraries: ResolvedLibrary[], +): Promise { const tasks: DownloadTask[] = []; - // --- client jar --------------------------------------------------------- - const client = version.downloads?.client; - if (client) { - tasks.push({ - url: client.url, - destination: versionJar(version.id), - sha1: client.sha1, - size: client.size, - label: `${version.id}.jar`, - }); - } - - // --- libraries and natives --------------------------------------------- - const { classpath, natives } = resolveLibraries(version, librariesDir()); - - for (const library of [...classpath, ...natives]) { + for (const library of libraries) { if (library.download?.url) { tasks.push({ url: library.download.url, @@ -96,6 +92,32 @@ export async function installVersion( } } + return tasks; +} + +export async function installVersion( + id: string, + options: InstallOptions = {}, +): Promise { + const version = await resolveVersionJson(id); + const tasks: DownloadTask[] = []; + + // --- client jar --------------------------------------------------------- + const client = version.downloads?.client; + if (client) { + tasks.push({ + url: client.url, + destination: versionJar(version.id), + sha1: client.sha1, + size: client.size, + label: `${version.id}.jar`, + }); + } + + // --- libraries and natives --------------------------------------------- + const { classpath, natives } = resolveLibraries(version, librariesDir()); + tasks.push(...(await libraryDownloadTasks([...classpath, ...natives]))); + // --- asset index and objects ------------------------------------------- let assetIndex: AssetIndex | undefined; let assetIndexId: string | undefined; diff --git a/launcher/src/main/minecraft/launch.ts b/launcher/src/main/minecraft/launch.ts index 40d4bb7..fe61957 100644 --- a/launcher/src/main/minecraft/launch.ts +++ b/launcher/src/main/minecraft/launch.ts @@ -8,6 +8,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { stat } from 'node:fs/promises'; import path from 'node:path'; import type { Argument, VersionJson } from './types.ts'; import { matchesRules } from './rules.ts'; @@ -24,9 +25,7 @@ import { } from '../paths.ts'; import { selectJavaFor, type JavaRuntime } from '../java-runtime.ts'; import { DEFAULT_PORT } from '../../shared/protocol.ts'; - -export const LAUNCHER_NAME = 'Ella'; -export const LAUNCHER_VERSION = '0.1.0'; +import { APP_NAME, APP_VERSION } from '../../shared/app.ts'; /** * Derives the UUID Minecraft itself uses for offline players: an RFC 4122 version 3 @@ -95,6 +94,42 @@ function collectArguments( return out; } +/** Raised before Java is spawned, so the cause is named rather than inferred. */ +export class MissingLibrariesError extends Error { + missing: string[]; + + constructor(missing: string[]) { + const names = missing.map((file) => path.basename(file)); + super( + `${missing.length} file(s) the game needs are missing, starting with ${names[0]}. ` + + 'Reinstall this version from the Versions tab to fetch them.', + ); + this.name = 'MissingLibrariesError'; + this.missing = missing; + } +} + +/** + * Refuses to launch with an incomplete classpath. + * + * A missing jar does not stop Java from starting; it surfaces much later as a + * `NoClassDefFoundError` deep inside the mod loader, naming a class rather than a file and + * pointing at no fix at all. That is how a Forge install which silently skipped its + * libraries presented itself. Checking here costs a few stat calls and turns a sixty-line + * stack trace into one sentence naming the file and what to do about it. + */ +async function requireClasspathPresent(entries: string[]): Promise { + const checks = await Promise.all( + entries.map(async (file) => ({ + file, + present: await stat(file).then((s) => s.isFile(), () => false), + })), + ); + + const missing = checks.filter((check) => !check.present).map((check) => check.file); + if (missing.length > 0) throw new MissingLibrariesError(missing); +} + export interface LaunchCommand { java: JavaRuntime; args: string[]; @@ -126,6 +161,8 @@ export async function buildLaunchCommand(options: LaunchOptions): Promise JSON.stringify({ parent: `${SLOT_NAMESPACE}:block/${slotModelName(slot)}` }, null, 2); /** A cube-shaped starting model, so a new block is visible before any Blockbench work. */ +/** + * A cube-shaped starting model, so a new block is visible before any Blockbench work. + * + * Self-contained rather than `{ parent: "block/cube_all" }`, and that is not a style + * choice. Blockbench keeps whatever parent it finds, so a model that starts with one still + * has it after geometry is added — and on 1.8.x a parent overrides the child's own + * elements outright, which renders the author's work as a plain cube. Starting with no + * parent means there is none to inherit into that trap. See shared/model-compat.ts. + * + * The elements and texture variables are what `block/cube_all` resolves to anyway, so + * nothing is lost on the versions where inheriting would have worked. + */ export const defaultBlockModel = (texture: string): string => - JSON.stringify({ parent: 'block/cube_all', textures: { all: texture } }, null, 2); + JSON.stringify( + { + textures: { + all: texture, + particle: texture, + }, + elements: [ + { + from: [0, 0, 0], + to: [16, 16, 16], + faces: { + down: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'down' }, + up: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'up' }, + north: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'north' }, + south: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'south' }, + west: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'west' }, + east: { uv: [0, 0, 16, 16], texture: '#all', cullface: 'east' }, + }, + }, + ], + }, + null, + 2, + ); /** A flat sprite starting model for items. */ export const defaultItemModel = (texture: string, handheld: boolean): string => @@ -284,12 +319,87 @@ async function writeFileAt(root: string, relative: string, content: string | Buf await writeFile(target, content); } +/** + * Writes the files for one slot. + * + * `target` is the model the slot points at, or null for an unbound slot: those still need + * files, otherwise the game logs a missing-model error for every empty slot in the pool on + * every reload. + */ +async function writeSlot( + packDir: string, + kind: EntryKind, + slot: number, + target: string | null, + renderLayer: string | undefined, +): Promise { + if (kind === 'item') { + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, + slotRedirectModel(target ?? 'item/generated'), + ); + return; + } + + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/blockstates/${slotName(kind, slot)}.json`, + slotBlockstate(slot), + ); + + const redirect = slotRedirectModel(target, renderLayer); + + /* + * The same model is written at two paths on purpose. + * + * A blockstate's `model` value is resolved relative to `models/block/` on 1.12.2 + * and older — the prefix is implicit — but is a full path from `models/` on 1.13 + * and newer. So `ella:block/slot_000` means `models/block/block/slot_000.json` + * on the old versions and `models/block/slot_000.json` on the new ones. + * + * Writing both lets one blockstate serve the whole range. The alternative is + * emitting a different `model` value per version, which needs the target version + * at write time — and the pack is written before any game connects. + * + * Note this quirk applies only to blockstates. A `parent` inside a model file is + * a full path on every version, which is why the item models resolve correctly + * with a single copy. + */ + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/block/${slotModelName(slot)}.json`, + redirect, + ); + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/block/block/${slotModelName(slot)}.json`, + redirect, + ); + await writeFileAt( + packDir, + `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, + blockItemModel(slot), + ); +} + +/** The model path a bound entry's slot redirects to. */ +const entryTarget = (project: EllaProject, entry: ProjectEntry): string => + `${project.namespace}:${entry.kind}/${entry.id}`; + +const entryRenderLayer = (entry: ProjectEntry): string | undefined => + typeof entry.settings.renderLayer === 'string' ? entry.settings.renderLayer : undefined; + /** * Regenerates the whole `ella` slot namespace from the project. * * The namespace is wiped first: stale blockstates from deleted entries would otherwise * keep rendering, which is exactly the kind of ghost that makes people restart the game * to "fix" something that was never broken. + * + * This is the expensive path — a full pool is four files per block slot plus one per item + * slot, so a default project regenerates several hundred files. Use it for changes that + * move bindings, names or the namespace; {@link writeEntrySlot} covers the rest. */ export async function writeSlotNamespace( projectRoot: string, @@ -310,61 +420,13 @@ export async function writeSlotNamespace( for (const kind of ['block', 'item'] as const) { for (let slot = 0; slot < project.slotPool[kind]; slot++) { const entry = bound.get(`${kind}:${slot}`); - // Unbound slots still need files, otherwise the game logs a missing-model error - // for every empty slot in the pool on every reload. - const target = entry - ? `${project.namespace}:${kind}/${entry.id}` - : null; - const renderLayer = - typeof entry?.settings.renderLayer === 'string' ? entry.settings.renderLayer : undefined; - - if (kind === 'block') { - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/blockstates/${slotName(kind, slot)}.json`, - slotBlockstate(slot), - ); - - const redirect = slotRedirectModel(target, renderLayer); - - /* - * The same model is written at two paths on purpose. - * - * A blockstate's `model` value is resolved relative to `models/block/` on 1.12.2 - * and older — the prefix is implicit — but is a full path from `models/` on 1.13 - * and newer. So `ella:block/slot_000` means `models/block/block/slot_000.json` - * on the old versions and `models/block/slot_000.json` on the new ones. - * - * Writing both lets one blockstate serve the whole range. The alternative is - * emitting a different `model` value per version, which needs the target version - * at write time — and the pack is written before any game connects. - * - * Note this quirk applies only to blockstates. A `parent` inside a model file is - * a full path on every version, which is why the item models resolve correctly - * with a single copy. - */ - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/block/${slotModelName(slot)}.json`, - redirect, - ); - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/block/block/${slotModelName(slot)}.json`, - redirect, - ); - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, - blockItemModel(slot), - ); - } else { - await writeFileAt( - packDir, - `assets/${SLOT_NAMESPACE}/models/item/${slotName(kind, slot)}.json`, - slotRedirectModel(target ?? 'item/generated'), - ); - } + await writeSlot( + packDir, + kind, + slot, + entry ? entryTarget(project, entry) : null, + entry ? entryRenderLayer(entry) : undefined, + ); } } @@ -372,3 +434,30 @@ export async function writeSlotNamespace( await writeFileAt(packDir, `assets/${SLOT_NAMESPACE}/lang/${filename}`, content); } } + +/** + * Rewrites the files for a single entry's slot, leaving the rest of the namespace alone. + * + * Settings reach the generated pack through exactly one thing: the render layer, baked + * into that slot's redirect model. Nothing else in the namespace — blockstates, item + * models, the lang files — depends on an entry's settings, only on its binding and its + * display name. So a settings change is a handful of writes, not a regeneration. + * + * Returns false for an unbound entry, which has no slot files to write. + */ +export async function writeEntrySlot( + projectRoot: string, + project: EllaProject, + entry: ProjectEntry, +): Promise { + if (entry.slot === null) return false; + + await writeSlot( + path.join(projectRoot, 'pack'), + entry.kind, + entry.slot, + entryTarget(project, entry), + entryRenderLayer(entry), + ); + return true; +} diff --git a/launcher/src/main/project.ts b/launcher/src/main/project.ts index df7b166..91d0f25 100644 --- a/launcher/src/main/project.ts +++ b/launcher/src/main/project.ts @@ -9,6 +9,7 @@ import { PROJECT_FORMAT_VERSION, PROJECT_MANIFEST, emptyProject, + withDefaults, isValidIdentifier, nextFreeSlot, defaultModelOutput, @@ -19,12 +20,15 @@ import { } from '../shared/project.ts'; import type { EntryKind, LocaleMap } from '../shared/protocol.ts'; import { defaultsFor } from '../shared/settings-schema.ts'; +import { findParentTrap, withoutParent } from '../shared/model-compat.ts'; import { projectsDir, projectDir, ensureDir } from './paths.ts'; +import { stashFiles, restoreStash } from './trash.ts'; import { defaultBlockModel, defaultItemModel, placeholderTexturePng, writeSlotNamespace, + writeEntrySlot, HIGHEST_KNOWN_PACK_FORMAT, } from './pack.ts'; @@ -61,7 +65,7 @@ export async function loadProject(root: string): Promise { ); } - return project; + return withDefaults(project); } /** Writes the manifest atomically. */ @@ -77,6 +81,8 @@ export interface ProjectSummary { namespace: string; root: string; entryCount: number; + /** The version it is authored against, so the list can say so before it is opened. */ + targetVersion: string | null; } export async function listProjects(): Promise { @@ -94,6 +100,7 @@ export async function listProjects(): Promise { namespace: project.namespace, root, entryCount: project.entries.length, + targetVersion: project.targetVersion, }); } catch { // A directory that is not a project is simply not listed. @@ -105,7 +112,7 @@ export async function listProjects(): Promise { export async function createProject( name: string, namespace: string, - targetVersions: string[] = [], + targetVersion: string | null = null, ): Promise<{ project: EllaProject; root: string }> { if (!isValidIdentifier(namespace)) { throw new ProjectError( @@ -119,7 +126,7 @@ export async function createProject( throw new ProjectError('PROJECT_EXISTS', `A project already exists at ${root}`); } - const project = { ...emptyProject(name, namespace), targetVersions }; + const project = { ...emptyProject(name, namespace), targetVersion }; await ensureDir(path.join(root, 'sources')); await ensureDir(path.join(root, 'pack', 'assets', namespace)); @@ -147,7 +154,7 @@ export async function createProject( export async function updateProjectInfo( root: string, project: EllaProject, - changes: { name?: string; namespace?: string; targetVersions?: string[] }, + changes: { name?: string; namespace?: string; targetVersion?: string | null }, ): Promise { const name = changes.name?.trim() ?? project.name; const namespace = changes.namespace?.trim() ?? project.namespace; @@ -194,8 +201,16 @@ export async function updateProjectInfo( await rewriteNamespaceReferences(root, namespace, project.namespace, entries); } - const updated: EllaProject = { ...project, name, namespace, entries, - targetVersions: changes.targetVersions ?? project.targetVersions }; + const updated: EllaProject = { + ...project, + name, + namespace, + entries, + // Undefined means "leave it alone"; null is a deliberate unbinding, so the two cannot + // collapse into a single `??`. + targetVersion: + changes.targetVersion === undefined ? project.targetVersion : changes.targetVersion, + }; await saveProject(root, updated); return updated; @@ -473,30 +488,84 @@ export async function renameEntry( return { project: updated, entry: renamed }; } +/** The stash a deleted entry's files wait in. See main/trash.ts. */ +export const entryStash = (id: string): string => `entry-${id}`; + +export interface DeletedEntry { + project: EllaProject; + entry: ProjectEntry; + /** Where it sat in the list, so restoring it does not send it to the bottom. */ + index: number; +} + export async function deleteEntry( root: string, project: EllaProject, id: string, options: { deleteFiles?: boolean } = {}, -): Promise { - const entry = project.entries.find((candidate) => candidate.id === id); - if (!entry) throw new ProjectError('UNKNOWN_ENTRY', `No entry named "${id}"`); +): Promise { + const index = project.entries.findIndex((candidate) => candidate.id === id); + if (index === -1) throw new ProjectError('UNKNOWN_ENTRY', `No entry named "${id}"`); + const entry = project.entries[index]; const updated = { ...project, entries: project.entries.filter((candidate) => candidate.id !== id), }; if (options.deleteFiles) { - // Textures and Blockbench sources are the author's work; only remove them when the - // caller explicitly asks, so a mis-click cannot destroy an afternoon of modelling. - for (const relative of [entry.model.output, entry.model.path]) { - await rm(path.join(root, ...relative.split('/')), { force: true }); - } + // Textures and Blockbench sources are the author's work; only removed when the caller + // explicitly asks, and even then moved aside rather than destroyed, so the undo the + // editor offers afterwards has something to put back. + await stashFiles(root, entryStash(id), [ + entry.model.output, + entry.model.path, + textureRelativePath(project, entry), + ]); } await saveProject(root, updated); - return updated; + return { project: updated, entry, index }; +} + +/** + * Puts a deleted entry back, files and all. + * + * The slot is not restored blindly: an entry created in the meantime may have taken it, and + * two entries on one slot is a live-editing bug that would outlast this session. A taken + * slot is exchanged for the next free one, and a full pool leaves the entry unbound — + * recoverable by restarting the game, unlike a corrupted binding. + */ +export async function restoreEntry( + root: string, + project: EllaProject, + entry: ProjectEntry, + index: number, +): Promise<{ project: EllaProject; entry: ProjectEntry }> { + if (project.entries.some((candidate) => candidate.id === entry.id)) { + throw new ProjectError('DUPLICATE_ID', `An entry named "${entry.id}" already exists`); + } + + const taken = new Set( + project.entries + .filter((candidate) => candidate.kind === entry.kind && candidate.slot !== null) + .map((candidate) => candidate.slot as number), + ); + + const slot = + entry.slot !== null && !taken.has(entry.slot) ? entry.slot : nextFreeSlot(project, entry.kind); + + const restored: ProjectEntry = { ...entry, slot }; + const entries = [...project.entries]; + entries.splice(Math.min(index, entries.length), 0, restored); + + // Files first: a manifest that lists an entry whose model is still in the trash would + // describe a project that does not exist. + await restoreStash(root, entryStash(entry.id)); + + const updated = { ...project, entries }; + await saveProject(root, updated); + return { project: updated, entry: restored }; } /** Rebuilds the slot namespace after any change that affects bindings or names. */ @@ -508,6 +577,19 @@ export async function syncPack( await writeSlotNamespace(root, project, packFormat); } +/** + * Rewrites one entry's slot after a change that cannot affect any other slot. + * + * See {@link writeEntrySlot} for why a settings change qualifies. + */ +export async function syncEntryPack( + root: string, + project: EllaProject, + entry: ProjectEntry, +): Promise { + await writeEntrySlot(root, project, entry); +} + // --------------------------------------------------------------------------- // Textures // --------------------------------------------------------------------------- @@ -639,6 +721,52 @@ export async function readEntryPreviews( ); } +/** + * Removes the `parent` from an entry's model, so its own geometry is what renders. + * + * See {@link findParentTrap} for why this is needed at all. The rewrite is deliberately + * minimal — one key removed, everything else untouched, two-space JSON like Blockbench + * writes — because this is the author's file and the next Blockbench save has to see + * something it recognises. + * + * @returns the parent that was removed and the file as it was, or null when there was + * nothing to fix. The original is returned rather than kept aside because it is + * what lets the editor offer to take the rewrite back. + */ +export async function removeModelParent( + root: string, + entry: ProjectEntry, +): Promise<{ parent: string; original: string } | null> { + const file = path.join(root, ...entry.model.output.split('/')); + + let original: string; + let model: Record; + try { + original = await readFile(file, 'utf8'); + model = JSON.parse(original) as Record; + } catch (error) { + throw new ProjectError( + 'BAD_MODEL', + `Could not read the model for "${entry.id}": ${(error as Error).message}`, + ); + } + + const trap = findParentTrap(model); + if (!trap) return null; + + await writeFile(file, `${JSON.stringify(withoutParent(model), null, 2)}\n`, 'utf8'); + return { parent: trap.parent, original }; +} + +/** Writes an entry's model file back verbatim. The inverse of a rewrite Ella made. */ +export async function writeModelFile( + root: string, + entry: ProjectEntry, + content: string, +): Promise { + await writeFile(path.join(root, ...entry.model.output.split('/')), content, 'utf8'); +} + export async function writeProjectFile( root: string, relative: string, diff --git a/launcher/src/main/session.ts b/launcher/src/main/session.ts index fae21ba..dda6f3c 100644 --- a/launcher/src/main/session.ts +++ b/launcher/src/main/session.ts @@ -17,12 +17,53 @@ import { launchGame, type RunningGame } from './minecraft/launch.ts'; import { findForgeVersionId } from './minecraft/forge.ts'; import { ensureAdapterInstalled } from './adapters.ts'; import { collectCrashDiagnostics, collectLaunchFailureDiagnostics } from './diagnostics.ts'; -import { loadProject, saveProject, syncPack, type ProjectError } from './project.ts'; -import { HIGHEST_KNOWN_PACK_FORMAT } from './pack.ts'; +import { + loadProject, + saveProject, + syncPack, + syncEntryPack, + type ProjectError, +} from './project.ts'; +import { HIGHEST_KNOWN_PACK_FORMAT, SLOT_NAMESPACE } from './pack.ts'; +import { purgeStashes } from './trash.ts'; import { loadConfig } from './config.ts'; export type GameStatus = 'stopped' | 'starting' | 'running' | 'connected'; +/** + * How a game run ended. + * + * `stopped` the user pressed Stop — Ella killed it + * `quit` a clean exit from inside the game + * `terminated` killed by something else: Task Manager, the OS, a parent shell + * `failed` a non-zero exit, the only case with something to diagnose + */ +export type GameExit = + | { kind: 'stopped' } + | { kind: 'quit' } + | { kind: 'terminated'; signal: string | null } + | { kind: 'failed'; code: number }; + +/** + * Reads an exit the way a user would describe it. + * + * Killing a process reports a null exit code, which is the same shape as being killed by + * anything else and is *not* the same thing as failing. Ella used to test `code !== 0`, + * so pressing Stop logged an error and opened the crash dialog — the opposite of what the + * button promised. Whether the stop was requested is knowledge only the session has, which + * is why it is a parameter rather than something inferred here. + */ +export function classifyExit( + requested: boolean, + code: number | null, + signal: string | null, +): GameExit { + if (requested) return { kind: 'stopped' }; + if (code === 0) return { kind: 'quit' }; + if (code === null) return { kind: 'terminated', signal }; + return { kind: 'failed', code }; +} + /** How many output lines to retain for a crash report. */ const MAX_RETAINED_OUTPUT = 400; @@ -45,6 +86,8 @@ export class Session extends EventEmitter { private server: EllaServer; private watcher = new ModelWatcher(); private game: RunningGame | null = null; + /** Set by {@link stopGame}, so a deliberate stop is not reported as a failure. */ + private stopRequested = false; private status: GameStatus = 'stopped'; private reloadTimer: NodeJS.Timeout | null = null; /** Tail of the running game's output, kept for crash diagnostics. */ @@ -131,9 +174,18 @@ export class Session extends EventEmitter { this.project = project; this.projectRoot = root; + // Files a previous session moved aside for an undo that was never taken. The offers + // themselves are long gone, so this is the moment they stop costing disk. + await purgeStashes(root); + await syncPack(root, project, this.packFormat()); this.watcher.stop(); - await this.watcher.watchDirectory(path.join(root, 'pack')); + // The `ella` namespace is Ella's own output, regenerated from the project. Watching it + // meant every settings change fed its own writes back in as a model change: a reload + // the game did not need, and a preview refresh in the editor for each of them. + await this.watcher.watchDirectory(path.join(root, 'pack'), { + ignore: (relative) => relative.startsWith(`assets/${SLOT_NAMESPACE}/`), + }); this.emit('project', project); this.emit('state', this.state); @@ -168,6 +220,22 @@ export class Session extends EventEmitter { if (options.push !== false && this.server.connected) await this.pushAll(); } + /** + * Adopts a project whose only change is confined to one entry's own slot. + * + * The same as {@link setProject} except for what it rewrites: one slot instead of the + * whole namespace. Settings changes arrive one per slider tick, and regenerating several + * hundred files for each of them made the editor feel frozen while it caught up. + * + * The caller has already persisted the project — this does not save it again. + */ + async setEntry(project: EllaProject, entry: ProjectEntry): Promise { + if (!this.projectRoot) throw new Error('No project open'); + this.project = project; + await syncEntryPack(this.projectRoot, project, entry); + this.emit('project', project); + } + /** The connected game knows its own pack format; otherwise fall back to the table. */ private packFormat(): number { return this.server.game?.hello.packFormat ?? HIGHEST_KNOWN_PACK_FORMAT; @@ -183,6 +251,9 @@ export class Session extends EventEmitter { const config = await loadConfig(); this.versionId = versionId; + // Cleared per run: a previous process that ignored its kill signal must not hand its + // pending "this was deliberate" to whatever exits next. + this.stopRequested = false; this.setStatus('starting'); // Without Forge there is no Ella mod, so the game runs but nothing syncs. Launching @@ -239,36 +310,54 @@ export class Session extends EventEmitter { // Captured now: `this.game` is cleared before the crash report is built. const command = this.game.command; - this.game.process.on('exit', (code) => { + this.game.process.on('exit', (code, signal) => { + const exit = classifyExit(this.stopRequested, code, signal); + this.stopRequested = false; this.game = null; this.setStatus('stopped'); - // Exit code 0 is a normal quit. Anything else means the game failed, and the user - // should not have to go hunting through folders to find out why. - if (code !== 0) { + if (exit.kind === 'quit') return; + + if (exit.kind === 'stopped') { + this.emit('log', { level: 'info', source: 'game', message: 'Game stopped' }); + return; + } + + // Killed from outside. The game did not fault, so there is no crash report to + // collect and nothing to diagnose; say what happened and stop there. + if (exit.kind === 'terminated') { this.emit('log', { - level: 'error', + level: 'warn', source: 'game', - message: `Game exited with code ${code}`, + message: `Game was terminated${exit.signal ? ` (${exit.signal})` : ''}`, }); - - void collectCrashDiagnostics(versionId, code, this.recentOutput, { - javaUsed: { - major: command.java.major, - version: command.java.version, - path: command.java.path, - }, - launchedVersionId: command.version.id, - }) - .then((diagnostics) => this.emit('crash', diagnostics)) - .catch((error: Error) => - this.emit('log', { - level: 'error', - source: 'ella', - message: `Could not collect crash diagnostics: ${error.message}`, - }), - ); + return; } + + // A non-zero exit is a real failure, and the user should not have to go hunting + // through folders to find out why. + this.emit('log', { + level: 'error', + source: 'game', + message: `Game exited with code ${exit.code}`, + }); + + void collectCrashDiagnostics(versionId, exit.code, this.recentOutput, { + javaUsed: { + major: command.java.major, + version: command.java.version, + path: command.java.path, + }, + launchedVersionId: command.version.id, + }) + .then((diagnostics) => this.emit('crash', diagnostics)) + .catch((error: Error) => + this.emit('log', { + level: 'error', + source: 'ella', + message: `Could not collect crash diagnostics: ${error.message}`, + }), + ); }); } @@ -335,6 +424,10 @@ export class Session extends EventEmitter { } stopGame(): void { + // Recorded before the kill so the exit handler can tell "the user pressed Stop" from + // "the game died". Killing a process yields a null exit code, which is otherwise + // indistinguishable from a crash. + if (this.game) this.stopRequested = true; this.game?.process.kill(); this.game = null; this.setStatus('stopped'); diff --git a/launcher/src/main/splash.ts b/launcher/src/main/splash.ts new file mode 100644 index 0000000..b001c43 --- /dev/null +++ b/launcher/src/main/splash.ts @@ -0,0 +1,220 @@ +/** + * The window Ella shows while it starts. + * + * Startup is not instant — the data layout is checked, the IPC server binds a port, the + * last project is reopened and its pack is regenerated — and until the main window is + * ready to paint there is nothing on screen at all. A launcher that shows nothing for a + * second after its icon is clicked reads as one that failed to launch, and gets clicked + * again. + * + * The markup is a template string loaded as a `data:` URL rather than a file. It needs no + * bundler entry, nothing to copy at packaging time, and cannot fail to resolve a path + * inside an asar. Everything it draws is inline CSS and SVG: no fonts, no images, no + * scripts, so it paints on the first frame. + */ + +import { BrowserWindow } from 'electron'; + +/** + * Shortest time the splash stays up. + * + * The cube takes about this long to assemble, and a splash that vanishes mid-animation is + * worse than no splash. Startup usually outruns it anyway, so in practice this only + * matters on a warm second launch. + */ +export const MIN_SPLASH_MS = 1500; + +const HTML = ` + + + + + + +
+
+ + + + + + + + +
Ella
+
__TAGLINE__
+
+
+ +`; + +/** + * Opens the splash window. + * + * Transparent and frameless so the card can round its own corners, and `skipTaskbar` so + * Ella never shows up twice in the taskbar during startup. + */ +export function createSplash(tagline: string): BrowserWindow { + const splash = new BrowserWindow({ + width: 400, + height: 300, + frame: false, + transparent: true, + resizable: false, + movable: true, + center: true, + show: false, + skipTaskbar: true, + alwaysOnTop: true, + // No preload and no Node: this window renders one static page and talks to nothing. + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + + const html = HTML.replace('__TAGLINE__', escapeHtml(tagline)); + void splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + splash.once('ready-to-show', () => splash.show()); + + return splash; +} + +const escapeHtml = (value: string): string => + value.replace(/[&<>"]/g, (character) => { + switch (character) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + default: return '"'; + } + }); diff --git a/launcher/src/main/textures.ts b/launcher/src/main/textures.ts index e617fc3..bdf2d67 100644 --- a/launcher/src/main/textures.ts +++ b/launcher/src/main/textures.ts @@ -13,7 +13,7 @@ * touched the model. */ -import { readFile, writeFile, rm } from 'node:fs/promises'; +import { readFile, writeFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import type { EllaProject, ProjectEntry } from '../shared/project.ts'; import { placeholderTexturePng } from './pack.ts'; @@ -212,13 +212,24 @@ export async function addTexture( * not. Faces still pointing at the removed variable are reported so the caller can warn * rather than silently producing a model that will not load. */ +export interface RemovedTexture { + key: string; + reference: string; + /** True when removing it also cleared the model's `particle` entry. */ + wasParticle: boolean; +} + export async function removeTexture( root: string, project: EllaProject, entry: ProjectEntry, key: string, options: { deleteFile?: boolean } = {}, -): Promise<{ textures: TextureVariable[]; orphanedFaces: string[] }> { +): Promise<{ + textures: TextureVariable[]; + orphanedFaces: string[]; + removed: RemovedTexture; +}> { const model = await readModel(root, entry); const textures = model.textures ?? {}; @@ -232,9 +243,8 @@ export async function removeTexture( delete textures[key]; // A particle entry pointing at the removed file would outlive it. - if (key !== PARTICLE_KEY && textures[PARTICLE_KEY] === reference) { - delete textures[PARTICLE_KEY]; - } + const wasParticle = key !== PARTICLE_KEY && textures[PARTICLE_KEY] === reference; + if (wasParticle) delete textures[PARTICLE_KEY]; model.textures = textures; await writeModel(root, entry, model); @@ -248,9 +258,47 @@ export async function removeTexture( } } - return { textures: await listTextures(root, project, entry), orphanedFaces }; + return { + textures: await listTextures(root, project, entry), + orphanedFaces, + removed: { key, reference, wasParticle }, + }; } +/** + * Puts a removed texture variable back, particle entry included. + * + * Only the model JSON is rewritten, which is the whole of what {@link removeTexture} does + * when the image is kept — and the editor never deletes it. A variable whose file really + * was deleted is not restorable here, and is reported as such rather than restored as a + * reference to nothing, which is the one shape that stops a model loading at all. + */ +export async function restoreTexture( + root: string, + project: EllaProject, + entry: ProjectEntry, + removed: RemovedTexture, +): Promise { + const relativePath = resolveReference(project, removed.reference); + if (relativePath && !(await fileExists(path.join(root, ...relativePath.split('/'))))) { + throw new ProjectError( + 'TEXTURE_FILE_GONE', + `The image "${removed.reference}" points at was deleted, so the variable cannot be restored`, + ); + } + + const model = await readModel(root, entry); + model.textures ??= {}; + model.textures[removed.key] = removed.reference; + if (removed.wasParticle) model.textures[PARTICLE_KEY] = removed.reference; + + await writeModel(root, entry, model); + return listTextures(root, project, entry); +} + +const fileExists = (target: string): Promise => + stat(target).then((entry) => entry.isFile(), () => false); + /** Points the `particle` variable at the same file as `key`, or clears it. */ export async function setParticleTexture( root: string, diff --git a/launcher/src/main/trash.ts b/launcher/src/main/trash.ts new file mode 100644 index 0000000..096746a --- /dev/null +++ b/launcher/src/main/trash.ts @@ -0,0 +1,120 @@ +/** + * Files an action moved aside instead of deleting. + * + * Deleting an entry's model and texture is the one editor action whose result cannot be + * typed back in, and the notification that follows it offers a way out — which is only + * true if the bytes still exist. So "delete the files too" moves them into + * `/.trash//`, keeping their project-relative layout, which is what makes + * putting them back need no bookkeeping beyond the slug. + * + * The stash lives for as long as the project stays open: it is purged on the next open, so + * it cannot grow without limit. That is not a recycle bin and is not offered as one — the + * undo it backs is a notification that lasts seconds. + * + * It sits outside `pack/` on purpose, so nothing here reaches the file watcher, an export, + * or the running game. + */ + +import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; + +/** Kept out of `pack/`, and dot-prefixed so it reads as Ella's own bookkeeping. */ +export const TRASH_DIR = '.trash'; + +const stashDir = (root: string, slug: string): string => path.join(root, TRASH_DIR, slug); + +/** + * Moves files out of the project, into the stash named by `slug`. + * + * A path that does not exist is skipped rather than failing: an entry may never have had a + * Blockbench source, and refusing to delete it over that would be absurd. + * + * @returns the project-relative paths that were actually moved + */ +export async function stashFiles( + root: string, + slug: string, + relativePaths: string[], +): Promise { + const moved: string[] = []; + + for (const relative of relativePaths) { + const from = path.join(root, ...relative.split('/')); + const to = path.join(stashDir(root, slug), ...relative.split('/')); + + await mkdir(path.dirname(to), { recursive: true }); + try { + await rename(from, to); + moved.push(relative); + } catch { + // Missing, or already moved by an earlier call for the same entry. + } + } + + return moved; +} + +/** + * Puts a stash back where it came from and removes it. + * + * Never overwrites: a file that exists again was recreated after the delete, and it is the + * newer one. Losing it to an undo of something else would be the very failure this module + * exists to prevent. + * + * @returns the project-relative paths that were restored + */ +export async function restoreStash(root: string, slug: string): Promise { + const directory = stashDir(root, slug); + const restored: string[] = []; + + for (const relative of await listStashed(directory)) { + const from = path.join(directory, ...relative.split('/')); + const to = path.join(root, ...relative.split('/')); + + // `rename` replaces an existing destination silently, so it is checked first. + if (await stat(to).then(() => true, () => false)) continue; + + await mkdir(path.dirname(to), { recursive: true }); + try { + await rename(from, to); + restored.push(relative); + } catch { + // Left in the stash; the purge on the next project open clears it. + } + } + + await rm(directory, { recursive: true, force: true }); + return restored; +} + +/** Everything under a stash, as paths relative to it. */ +async function listStashed(directory: string, prefix = ''): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + + const files: string[] = []; + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listStashed(path.join(directory, entry.name), relative))); + } else { + files.push(relative); + } + } + return files; +} + +/** + * Empties the trash. + * + * Called when a project is opened rather than on a timer: the undo offers a stash backs are + * gone by then anyway, and tying the lifetime to something the user does keeps it + * predictable — nothing disappears while the project is open. + */ +export async function purgeStashes(root: string): Promise { + await rm(path.join(root, TRASH_DIR), { recursive: true, force: true }); +} diff --git a/launcher/src/main/undo.ts b/launcher/src/main/undo.ts new file mode 100644 index 0000000..52fa8d3 --- /dev/null +++ b/launcher/src/main/undo.ts @@ -0,0 +1,77 @@ +/** + * One-shot inverses, handed to the renderer as an opaque token. + * + * How to reverse an action belongs next to the action. The alternative — the renderer + * holding the manifest fragment, the file paths and the ordering it would need to undo a + * delete — would spread knowledge of the project format into the button that offers the + * undo, and would need a new IPC call for every action that ever becomes undoable. Here it + * is one `offer(...)` beside the thing that happened, and one channel for all of them. + * + * Offers are one-shot and bounded. They back a notification that lives for seconds; keeping + * more than the last handful would mean holding closures over a project that has since + * moved on, and an undo that quietly applies to the wrong state is worse than no undo. + */ + +import { randomUUID } from 'node:crypto'; + +export interface UndoOffer { + token: string; + /** i18n key describing what happened, translated by the renderer. */ + messageKey: string; + values: Record; +} + +/** + * How many offers stay live. + * + * Deep enough that a burst of edits does not drop the one still on screen, shallow enough + * that nothing lingers long past the notification it belongs to. + */ +const MAX_OFFERS = 8; + +export class UndoRegistry { + private inverses = new Map Promise>(); + + /** Registers a way back and returns what the renderer needs to offer it. */ + offer( + messageKey: string, + values: Record, + inverse: () => Promise, + ): UndoOffer { + const token = randomUUID(); + this.inverses.set(token, inverse); + + // Map iterates in insertion order, so the oldest is the first key. + while (this.inverses.size > MAX_OFFERS) { + const oldest = this.inverses.keys().next().value; + if (oldest === undefined) break; + this.inverses.delete(oldest); + } + + return { token, messageKey, values }; + } + + /** + * Runs an inverse, once. + * + * Removed before it runs rather than after: a failed undo has already done whatever part + * of its work it managed, and running it a second time would compound that rather than + * retry it. + */ + async run(token: string): Promise { + const inverse = this.inverses.get(token); + if (!inverse) { + const error = new Error('This change can no longer be undone') as Error & { code: string }; + error.code = 'UNDO_EXPIRED'; + throw error; + } + + this.inverses.delete(token); + await inverse(); + } + + /** Drops every offer. Used when the project they refer to is no longer open. */ + clear(): void { + this.inverses.clear(); + } +} diff --git a/launcher/src/main/version-change.ts b/launcher/src/main/version-change.ts new file mode 100644 index 0000000..b63918a --- /dev/null +++ b/launcher/src/main/version-change.ts @@ -0,0 +1,141 @@ +/** + * Moving an open project between Minecraft versions. + * + * Two halves: {@link planVersionChange} reads every entry's model and reports what would go + * wrong on the version about to be launched, and {@link applyVersionChange} rewrites those + * files and records the new version on the project. + * + * They are separate because the answer to "what will this break?" has to reach the user + * *before* anything is written — a migration that happened on the way to a dialog would be + * a migration nobody agreed to. What counts as broken, and how it is fixed, lives in + * shared/version-compat.ts; this module only does the file I/O around it. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { EllaProject } from '../shared/project.ts'; +import { + inspectModel, + migrateModel, + isVersionChange, + type CompatIssue, +} from '../shared/version-compat.ts'; +import { saveProject } from './project.ts'; + +export interface VersionChangeFinding extends CompatIssue { + entryId: string; +} + +export interface VersionChangePlan { + /** The version the project is bound to, or null when nothing has bound it yet. */ + from: string | null; + to: string; + /** True when this launch is a change the user should be asked about. */ + needsConfirmation: boolean; + findings: VersionChangeFinding[]; + /** How many model files Ella would rewrite — entries, not findings, since two issues in + * one model are still one file. */ + fixable: number; +} + +const modelPath = (root: string, output: string): string => + path.join(root, ...output.split('/')); + +/** Reads an entry's model, or null when it is missing or not JSON. */ +async function readModel( + root: string, + output: string, +): Promise | null> { + try { + const parsed = JSON.parse(await readFile(modelPath(root, output), 'utf8')) as unknown; + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : null; + } catch { + // A missing or malformed model is not a version problem, and validateForExport already + // reports it properly. Saying nothing here beats inventing a second diagnosis for it. + return null; + } +} + +/** + * What launching `to` would mean for this project. + * + * The findings are collected whether or not the version actually differs: the same check + * answers "is this file wrong for the version I am already on?", which is worth knowing. + */ +export async function planVersionChange( + root: string, + project: EllaProject, + to: string, +): Promise { + const findings: VersionChangeFinding[] = []; + + for (const entry of project.entries) { + const model = await readModel(root, entry.model.output); + if (!model) continue; + + for (const issue of inspectModel(model, to)) { + findings.push({ ...issue, entryId: entry.id }); + } + } + + const fixable = new Set( + findings.filter((finding) => finding.fixable).map((finding) => finding.entryId), + ); + + return { + from: project.targetVersion, + to, + needsConfirmation: isVersionChange(project.targetVersion, to), + findings, + fixable: fixable.size, + }; +} + +export interface VersionChangeResult { + project: EllaProject; + /** Entry ids whose model files were rewritten. */ + migrated: string[]; +} + +/** + * Binds the project to `to`, optionally rewriting the models that need it first. + * + * Files are written before the manifest: if a rewrite fails, the project still says it is + * bound to the version its files are actually written for. + * + * The rewrite keeps Blockbench's own formatting — two-space JSON with a trailing newline — + * because these are the author's files and the next save has to see something it + * recognises rather than a diff of the whole document. + */ +export async function applyVersionChange( + root: string, + project: EllaProject, + to: string, + options: { migrate: boolean }, +): Promise { + const migrated: string[] = []; + + if (options.migrate) { + for (const entry of project.entries) { + const model = await readModel(root, entry.model.output); + if (!model) continue; + + const { model: rewritten, applied } = migrateModel(model, to); + if (applied.length === 0) continue; + + await writeFile( + modelPath(root, entry.model.output), + `${JSON.stringify(rewritten, null, 2)}\n`, + 'utf8', + ); + migrated.push(entry.id); + } + } + + const updated: EllaProject = { ...project, targetVersion: to }; + await saveProject(root, updated); + + return { project: updated, migrated }; +} diff --git a/launcher/src/preload/index.ts b/launcher/src/preload/index.ts index e3c73b6..29c854e 100644 --- a/launcher/src/preload/index.ts +++ b/launcher/src/preload/index.ts @@ -40,14 +40,17 @@ const api: EllaApi = { projects: { list: () => invoke(CHANNELS.projectsList), - create: (name, namespace, targetVersions) => - invoke(CHANNELS.projectsCreate, name, namespace, targetVersions), + create: (name, namespace, targetVersion) => + invoke(CHANNELS.projectsCreate, name, namespace, targetVersion), open: (root) => invoke(CHANNELS.projectsOpen, root), current: () => invoke(CHANNELS.projectsCurrent), measure: () => invoke(CHANNELS.projectsMeasure), delete: (root) => invoke(CHANNELS.projectsDelete, root), close: () => invoke(CHANNELS.projectsClose), updateInfo: (changes) => invoke(CHANNELS.projectsUpdateInfo, changes), + planVersionChange: (versionId) => invoke(CHANNELS.projectsPlanVersionChange, versionId), + applyVersionChange: (versionId, migrate) => + invoke(CHANNELS.projectsApplyVersionChange, versionId, migrate), }, entries: { @@ -64,11 +67,16 @@ const api: EllaApi = { invoke(CHANNELS.entriesRemoveTexture, id, key, deleteFile), setParticleTexture: (id, key) => invoke(CHANNELS.entriesSetParticle, id, key), revealTexture: (id) => invoke(CHANNELS.entriesRevealTexture, id), + removeModelParent: (id) => invoke(CHANNELS.entriesRemoveModelParent, id), previews: () => invoke(CHANNELS.entriesPreviews), give: (id) => invoke(CHANNELS.entriesGive, id), place: (id) => invoke(CHANNELS.entriesPlace, id), }, + undo: { + run: (token) => invoke(CHANNELS.undoRun, token), + }, + game: { launch: (versionId) => invoke(CHANNELS.gameLaunch, versionId), stop: () => invoke(CHANNELS.gameStop), @@ -83,6 +91,7 @@ const api: EllaApi = { }, blockbench: { + resolve: () => invoke(CHANNELS.blockbenchResolve), pluginStatus: () => invoke(CHANNELS.blockbenchPluginStatus), installPlugin: () => invoke(CHANNELS.blockbenchInstallPlugin), }, @@ -105,6 +114,7 @@ const api: EllaApi = { progress: (handler) => subscribe(EVENTS.progress, handler), crash: (handler) => subscribe(EVENTS.crash, handler), files: (handler) => subscribe(EVENTS.files, handler), + undo: (handler) => subscribe(EVENTS.undo, handler), }, }; diff --git a/launcher/src/renderer/src/App.tsx b/launcher/src/renderer/src/App.tsx index ea6fcfd..a5d9a57 100644 --- a/launcher/src/renderer/src/App.tsx +++ b/launcher/src/renderer/src/App.tsx @@ -1,6 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useI18n } from './i18n.tsx'; import { useSession } from './session.ts'; +import { useWorkflowFacts } from './facts.ts'; +import { NAV, type View } from './navigation.ts'; +import { APP_VERSION } from '../../shared/app.ts'; +import { workflowSteps, completedCount } from '../../shared/workflow.ts'; +import { HomeView } from './views/HomeView.tsx'; import { VersionsView } from './views/VersionsView.tsx'; import { ProjectView } from './views/ProjectView.tsx'; import { EditorView } from './views/EditorView.tsx'; @@ -10,42 +15,95 @@ import { SettingsView } from './views/SettingsView.tsx'; import { StatusBar } from './components/StatusBar.tsx'; import { QuickLaunch } from './components/QuickLaunch.tsx'; import { CrashDialog } from './components/CrashDialog.tsx'; -import { Icon, type IconName } from './components/Icon.tsx'; - -type View = 'versions' | 'project' | 'editor' | 'logs' | 'export' | 'settings'; - -const NAV: Array<{ id: View; labelKey: string; icon: IconName }> = [ - { id: 'versions', labelKey: 'nav.versions', icon: 'versions' }, - { id: 'project', labelKey: 'nav.project', icon: 'project' }, - { id: 'editor', labelKey: 'nav.editor', icon: 'editor' }, - { id: 'logs', labelKey: 'nav.logs', icon: 'logs' }, - { id: 'export', labelKey: 'nav.export', icon: 'export' }, - { id: 'settings', labelKey: 'nav.settings', icon: 'settings' }, -]; +import { VersionChangeDialog } from './components/VersionChangeDialog.tsx'; +import { useToast } from './components/Toast.tsx'; +import { Icon } from './components/Icon.tsx'; export function App() { const { t } = useI18n(); + const toast = useToast(); const session = useSession(); - const [view, setView] = useState('project'); + const facts = useWorkflowFacts(session); + const [view, setView] = useState('home'); const [selectedEntry, setSelectedEntry] = useState(null); + /* + * Undo offers, subscribed once for the whole app. + * + * Main announces them rather than returning them, so the notification appears wherever + * the change was made — including changes nothing on screen asked for, like the rewrite + * that follows fixing a model. Which action it was is main's business; here it is a + * message and a token. + */ + useEffect( + () => + window.ella.on.undo((offer) => + toast.undoable(t(offer.messageKey, offer.values), () => + window.ella.undo.run(offer.token), + ), + ), + [toast, t], + ); + + const steps = workflowSteps(facts); + const remaining = steps.length - completedCount(steps); + /** Jumping straight to the editor is the common path after creating an entry. */ const openEntry = (id: string): void => { setSelectedEntry(id); setView('editor'); }; + // Ctrl+1…7 switches view. The shortcut is named in each item's tooltip rather than + // printed in the sidebar, which would cost a column of width to teach one thing once. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (!event.ctrlKey || event.altKey || event.shiftKey) return; + const index = Number(event.key) - 1; + const target = NAV[index]; + if (!target) return; + if (target.needsProject && !session.project) return; + event.preventDefault(); + setView(target.id); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [session.project]); + + /** Count or state for the view behind a row, or null when it has nothing to report. */ + const badgeFor = (id: View): { text: string; tone: string } | null => { + if (id === 'home' && remaining > 0) return { text: String(remaining), tone: 'attention' }; + if (id === 'versions' && facts.connected) return { text: t('home.live.on'), tone: 'live' }; + if (id === 'versions' && facts.installedVersions > 0) { + return { text: String(facts.installedVersions), tone: '' }; + } + if (id === 'project' && facts.entryCount > 0) { + return { text: String(facts.entryCount), tone: '' }; + } + return null; + }; + return (
- {view === 'versions' && } + {view === 'home' && ( + + )} + {view === 'versions' && } {view === 'project' && } {view === 'editor' && ( )} {view === 'logs' && } {view === 'export' && } - {view === 'settings' && } + {view === 'settings' && }
@@ -96,6 +174,18 @@ export function App() { {session.crash && ( )} + + {/* Rendered here rather than beside the button that triggered it: three different + views can start a launch, and the answer is the same wherever it came from. */} + {session.versionChange && session.project && ( + + )}
); } diff --git a/launcher/src/renderer/src/components/EmptyState.tsx b/launcher/src/renderer/src/components/EmptyState.tsx new file mode 100644 index 0000000..b4f7540 --- /dev/null +++ b/launcher/src/renderer/src/components/EmptyState.tsx @@ -0,0 +1,39 @@ +/** + * What a list shows before it has anything in it. + * + * An empty list is the moment a user is most likely to be stuck, so it is the worst place + * to print "nothing here" and stop. Each one names what is missing, says why it matters, + * and carries the button that fixes it — the same button they would otherwise have to go + * find on another tab. + */ + +import { Icon, type IconName } from './Icon.tsx'; + +interface Props { + icon: IconName; + title: string; + text?: string; + action?: { + label: string; + icon?: IconName; + onClick: () => void; + }; +} + +export function EmptyState({ icon, title, text, action }: Props) { + return ( +
+
+ +
+
{title}
+ {text &&
{text}
} + {action && ( + + )} +
+ ); +} diff --git a/launcher/src/renderer/src/components/EntryHeader.tsx b/launcher/src/renderer/src/components/EntryHeader.tsx index c4b8085..5785f57 100644 --- a/launcher/src/renderer/src/components/EntryHeader.tsx +++ b/launcher/src/renderer/src/components/EntryHeader.tsx @@ -25,7 +25,8 @@ interface Props { entry: ProjectEntry; namespace: string; preview: EntryPreviewDto | undefined; - onError: (message: string) => void; + /** Reports what went wrong, or null once it no longer applies. */ + onError: (message: string | null) => void; /** Called after a rename, so the caller can follow the entry to its new id. */ onRenamed: (newId: string) => void; } @@ -51,6 +52,9 @@ export function EntryHeader({ entry, namespace, preview, onError, onRenamed }: P const next = id.trim(); if (next === entry.id) return; + // Whatever went wrong last time was about the value being replaced. + onError(null); + if (!isValidIdentifier(next)) { setId(entry.id); onError(t('entry.idHelp')); @@ -84,6 +88,7 @@ export function EntryHeader({ entry, namespace, preview, onError, onRenamed }: P return; } + onError(null); const displayName = trimmedFr ? { en: trimmedEn, fr: trimmedFr } : { en: trimmedEn }; const result = await window.ella.entries.update(entry.id, { displayName }); if (!result.ok) onError(result.message); @@ -94,7 +99,7 @@ export function EntryHeader({ entry, namespace, preview, onError, onRenamed }: P {/* Left of the fields on purpose: while renaming or retyping a display name, the model is the thing that tells you which entry you are actually editing. */}
- +
diff --git a/launcher/src/renderer/src/components/ErrorBanner.tsx b/launcher/src/renderer/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..eace7a6 --- /dev/null +++ b/launcher/src/renderer/src/components/ErrorBanner.tsx @@ -0,0 +1,40 @@ +/** + * The failure of the last thing you asked for, shown where you asked for it. + * + * A toast would be wrong for these: unlike a confirmation, a failure often names something + * that still needs fixing on this page, and it should stay until it is read. But "stays" + * had become "stays forever" — the message outlived the state it described, sitting in red + * above an entry it no longer had anything to do with. + * + * So it can always be closed, and the pages that own one clear it when the thing it was + * about changes. A banner that cannot be dismissed is an assertion; this is a report. + */ + +import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; + +interface Props { + /** Null renders nothing, so callers can hand over their error state directly. */ + message: string | null; + onDismiss: () => void; +} + +export function ErrorBanner({ message, onDismiss }: Props) { + const { t } = useI18n(); + if (!message) return null; + + return ( +
+ +
{message}
+ +
+ ); +} diff --git a/launcher/src/renderer/src/components/Icon.tsx b/launcher/src/renderer/src/components/Icon.tsx index 30d72a1..4009dff 100644 --- a/launcher/src/renderer/src/components/Icon.tsx +++ b/launcher/src/renderer/src/components/Icon.tsx @@ -11,6 +11,7 @@ */ export type IconName = + | 'home' | 'versions' | 'project' | 'editor' @@ -27,17 +28,27 @@ export type IconName = | 'folder' | 'refresh' | 'block' - | 'item'; + | 'item' + | 'check' + | 'arrow' + | 'info' + | 'alert' + | 'close' + | 'brush' + | 'download' + | 'bolt'; /** 24×24 viewBox, stroked, so every glyph scales and tints the same way. */ const PATHS: Record = { + home: 'M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z M9.5 21v-6h5v6', // A cube, drawn as a hexagon outline plus the three edges meeting at the front corner. versions: 'M12 2 21 7v10l-9 5-9-5V7z M12 12 21 7 M12 12v10 M12 12 3 7', block: 'M12 2 21 7v10l-9 5-9-5V7z M12 12 21 7 M12 12v10 M12 12 3 7', project: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', - // Sliders: three tracks with a handle on each. - editor: 'M4 6h16 M4 12h16 M4 18h16 M9 6v0 M15 12v0 M7 18v0', + // Sliders: three tracks, each with a handle drawn as a bar across it. Dots would be + // shorter to write but a zero-length segment renders as nothing at small sizes. + editor: 'M4 6h16 M4 12h16 M4 18h16 M9 4v4 M15 10v4 M7 16v4', logs: 'M4 5h16v14H4z M7 9l3 3-3 3 M13 15h4', export: 'M12 3v12 M8 11l4 4 4-4 M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2', settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 0 0-2-1.2L14.2 3H9.8l-.4 2.7a7 7 0 0 0-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 2 1.2l.4 2.7h4.4l.4-2.7a7 7 0 0 0 2-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z', @@ -51,10 +62,19 @@ const PATHS: Record = { external: 'M14 4h6v6 M20 4l-8 8 M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5', refresh: 'M20 12a8 8 0 1 1-2.3-5.7 M20 4v4h-4', item: 'M12 3l8 4.5v9L12 21l-8-4.5v-9z M4 7.5l8 4.5 8-4.5 M12 12v9', + check: 'M5 12.5 10 17.5 19 7', + arrow: 'M5 12h13 M12.5 6l6 6-6 6', + info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z M12 11v5 M12 8v0', + alert: 'M12 3.5 22 20H2z M12 10v4 M12 17v0', + close: 'M6 6l12 12 M18 6 6 18', + // A brush, standing for Blockbench — the tool Ella hands the model over to. + brush: 'M15.5 3.5 20.5 8.5 11 18l-5-5z M6 13l-2.5 6.5L10 17 M14 5l5 5', + download: 'M12 3v12 M7.5 10.5 12 15l4.5-4.5 M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2', + bolt: 'M13 2 4 14h7l-1 8 9-12h-7z', }; /** Glyphs whose shape reads better filled than stroked. */ -const FILLED: ReadonlySet = new Set(['play', 'stop']); +const FILLED: ReadonlySet = new Set(['play', 'stop', 'bolt']); interface Props { name: IconName; diff --git a/launcher/src/renderer/src/components/ModelPreview.tsx b/launcher/src/renderer/src/components/ModelPreview.tsx index 25aafa1..3d8f6fb 100644 --- a/launcher/src/renderer/src/components/ModelPreview.tsx +++ b/launcher/src/renderer/src/components/ModelPreview.tsx @@ -1,27 +1,77 @@ /** - * Canvas preview of a block model. + * Canvas preview of a block model, turnable with the pointer. * - * See model-preview.ts for the projection. This component owns only the drawing: loading - * the texture, mapping it onto each face, and shading. + * See model-preview.ts for the projection. This component owns the drawing — loading the + * texture, mapping it onto each face, shading — and the camera the user is dragging. + * + * Rotation is opt-in because the card grids put previews inside buttons, where a drag has + * to stay a click on the card rather than turning a thumbnail nobody asked to turn. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState, type PointerEvent, type KeyboardEvent } from 'react'; import { parseModel, drawOrder, - screenBounds, + fitScale, + modelCentre, + project, + turn, + DEFAULT_VIEW, FACE_SHADE, type ParsedModel, + type ViewAngles, } from '../../../shared/model-preview.ts'; import type { EntryPreviewDto } from '../../../shared/ipc.ts'; interface Props { preview: EntryPreviewDto | undefined; size: number; + /** Enables drag-to-rotate, and the keyboard equivalent. */ + interactive?: boolean; + /** Hint shown on hover, and the accessible name of the canvas. */ + label?: string; } -export function ModelPreview({ preview, size }: Props) { +/** Radians per pixel dragged. A full turn takes a little over half a screen width. */ +const DRAG_SPEED = 0.011; + +/** Radians per arrow key press: an eighth of a turn, so four presses show the far side. */ +const KEY_STEP = Math.PI / 8; + +export function ModelPreview({ preview, size, interactive = false, label }: Props) { const canvasRef = useRef(null); + const [view, setView] = useState(DEFAULT_VIEW); + const [texture, setTexture] = useState(null); + + const model = useMemo(() => (preview ? parseModel(preview.model) : null), [preview]); + // Fitting is view-independent by design, so it survives a rotation without recomputing. + const scale = useMemo(() => (model ? fitScale(model, size) : 0), [model, size]); + + // A different entry starts from the default angle: the camera belongs to the viewer's + // inspection of one model, not to the panel. + useEffect(() => { + setView(DEFAULT_VIEW); + }, [preview?.id]); + + // Held as state rather than loaded per draw, so dragging does not re-decode the texture + // on every pointer move. + useEffect(() => { + setTexture(null); + + const uri = preview?.textureDataUri; + if (!uri) return; + + let cancelled = false; + const image = new Image(); + image.onload = () => { + if (!cancelled) setTexture(image); + }; + image.src = uri; + + return () => { + cancelled = true; + }; + }, [preview?.textureDataUri]); useEffect(() => { const canvas = canvasRef.current; @@ -37,42 +87,106 @@ export function ModelPreview({ preview, size }: Props) { context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, size, size); - const model = preview ? parseModel(preview.model) : null; - if (!model) return; + // The texture may still be loading; the shape is drawn untextured until it arrives. + if (model) render(context, model, size, scale, view, texture); + }, [model, size, scale, view, texture]); + + const drag = useRef<{ pointerId: number; x: number; y: number } | null>(null); + + const onPointerDown = (event: PointerEvent): void => { + if (!interactive || !model) return; + // Stops the drag from also being read as a press on whatever contains the preview. + event.preventDefault(); + drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent): void => { + const current = drag.current; + if (!current || current.pointerId !== event.pointerId) return; + + const dx = event.clientX - current.x; + const dy = event.clientY - current.y; + current.x = event.clientX; + current.y = event.clientY; + + // Turntable feel: the surface under the pointer follows it, so the camera orbits the + // other way for yaw, and dragging down tips the top of the model towards the viewer. + setView((angles) => turn(angles, -dx * DRAG_SPEED, dy * DRAG_SPEED)); + }; + + const onPointerEnd = (event: PointerEvent): void => { + if (drag.current?.pointerId !== event.pointerId) return; + drag.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; - // The texture may still be loading; draw untextured first so the shape appears, then - // redraw with the image once it is ready. - render(context, model, size, null); + const onKeyDown = (event: KeyboardEvent): void => { + if (!interactive || !model) return; - if (!preview?.textureDataUri) return; - const image = new Image(); - image.onload = () => { - context.clearRect(0, 0, size, size); - render(context, model, size, image); + // Each key turns the model the way dragging in that direction would. + const step: Record = { + ArrowLeft: [KEY_STEP, 0], + ArrowRight: [-KEY_STEP, 0], + ArrowUp: [0, -KEY_STEP], + ArrowDown: [0, KEY_STEP], }; - image.src = preview.textureDataUri; - }, [preview, size]); - return ; + if (event.key === 'Home' || event.key === 'Escape') { + setView(DEFAULT_VIEW); + } else if (step[event.key]) { + const [yaw, pitch] = step[event.key]; + setView((angles) => turn(angles, yaw, pitch)); + } else { + return; + } + + // Arrow keys would otherwise scroll the panel out from under the preview. + event.preventDefault(); + }; + + return ( + interactive && setView(DEFAULT_VIEW)} + onKeyDown={onKeyDown} + /> + ); } function render( context: CanvasRenderingContext2D, model: ParsedModel, size: number, + scale: number, + view: ViewAngles, image: HTMLImageElement | null, ): void { - // Fit the model to the canvas with a small margin, whatever its extents. - const probe = screenBounds(model, 1); - const scale = (size * 0.86) / Math.max(probe.width, probe.height, 1); - - const bounds = screenBounds(model, scale); - const offsetX = size / 2 - (bounds.minX + bounds.maxX) / 2; - const offsetY = size / 2 - (bounds.minY + bounds.maxY) / 2; + // Centred on the model's own centre rather than on the bounding box of this angle, so + // rotating spins the model in place instead of sliding it around the canvas. + const centre = modelCentre(model); + const [centreX, centreY] = project(centre[0], centre[1], centre[2], scale, view); + const offsetX = size / 2 - centreX; + const offsetY = size / 2 - centreY; context.imageSmoothingEnabled = false; - for (const { element, face, geometry } of drawOrder(model, scale)) { + for (const { element, face, geometry } of drawOrder(model, scale, view)) { const uv = element.faces[face]?.uv; const [u1, v1, u2, v2] = uv && uv.length === 4 ? uv : [0, 0, 16, 16]; @@ -101,7 +215,7 @@ function render( context.fillRect(0, 0, 1.01, 1.01); } - // Fixed directional shading, standing in for the game's own face lighting. + // Vanilla's fixed per-direction face lighting. const shade = FACE_SHADE[face]; if (shade < 1) { context.fillStyle = `rgba(0, 0, 0, ${1 - shade})`; diff --git a/launcher/src/renderer/src/components/QuickLaunch.tsx b/launcher/src/renderer/src/components/QuickLaunch.tsx index 21da08b..6b9eb13 100644 --- a/launcher/src/renderer/src/components/QuickLaunch.tsx +++ b/launcher/src/renderer/src/components/QuickLaunch.tsx @@ -7,6 +7,11 @@ * * Populated from disk rather than the manifest, so it appears instantly and still works * with no network. + * + * The selection follows the open project rather than the last launch: a project is bound to + * the version it was authored for, and opening another one has to bring its version with + * it. Otherwise switching projects quietly leaves the previous project's version armed — + * one click from a run whose breakages are all silent. */ import { useEffect, useState } from 'react'; @@ -24,6 +29,7 @@ export function QuickLaunch({ session }: { session: SessionHook }) { const { status, versionId } = session.state; const running = status !== 'stopped'; + const targetVersion = session.project?.targetVersion ?? null; useEffect(() => { void (async () => { @@ -41,6 +47,15 @@ export function QuickLaunch({ session }: { session: SessionHook }) { // Re-reads after a launch or an install/uninstall changes the session state. }, [status]); + // Opening a project arms its own version. Deliberately overwrites whatever was selected: + // the previous choice belonged to the previous project. + // + // Also re-arms it when a run ends, so the control comes to rest on the project's version + // rather than keeping a one-off launch armed for the next click. + useEffect(() => { + if (!running && targetVersion) setSelected(targetVersion); + }, [targetVersion, running]); + // Follow the running version, so the control always shows what is actually going on. useEffect(() => { if (running && versionId) setSelected(versionId); @@ -49,7 +64,7 @@ export function QuickLaunch({ session }: { session: SessionHook }) { const launch = async (): Promise => { if (!selected) return; setBusy(true); - const result = await window.ella.game.launch(selected); + const result = await session.requestLaunch(selected); setError(result.ok ? null : result.message); setBusy(false); }; @@ -63,8 +78,19 @@ export function QuickLaunch({ session }: { session: SessionHook }) { const current = versions.find((entry) => entry.id === selected); const canLaunch = Boolean(current?.javaAvailable) && !busy && selected !== ''; + // Two different situations, and the difference matters: the project's version is armed + // and something else was picked, or the project's version cannot be armed at all. + const targetInstalled = + targetVersion !== null && versions.some((entry) => entry.id === targetVersion); + const differsFromProject = targetVersion !== null && selected !== targetVersion; + return (
+
+ + {t('quick.title')} +
+ {error &&
{error}
} {versions.length === 0 ? ( @@ -85,24 +111,51 @@ export function QuickLaunch({ session }: { session: SessionHook }) { ))} + {/* The project's binding, said before the launch rather than in the dialog that + would follow it. Someone who picked another version on purpose gets a + reminder; someone who did it by accident gets a chance to notice. */} + {differsFromProject && ( +
+ + {targetInstalled + ? t('quick.projectTargets', { version: targetVersion }) + : t('quick.projectTargetsMissing', { version: targetVersion })} +
+ )} + {/* Says up front whether this launch will actually sync, rather than letting the user discover it from a silent absence of live editing. */} {current && current.adapterStatus !== 'built' && ( -
{t('quick.vanillaOnly')}
+
+ + {t('quick.vanillaOnly')} +
)} {current && !current.javaAvailable && (
+ {t('versions.javaMissing', { java: current.requiredJava })}
)} {running ? ( ) : ( - )} diff --git a/launcher/src/renderer/src/components/QuickNewEntry.tsx b/launcher/src/renderer/src/components/QuickNewEntry.tsx index 60559c4..3f90c76 100644 --- a/launcher/src/renderer/src/components/QuickNewEntry.tsx +++ b/launcher/src/renderer/src/components/QuickNewEntry.tsx @@ -2,44 +2,46 @@ * Compact entry creation, for the editor sidebar. * * The full form lives in the Project view; this exists so that adding a block during a - * modelling session does not mean leaving the editor. It asks for a name and nothing - * else — the identifier is derived, and the French name and settings are editable + * modelling session does not mean leaving the editor. It asks for a name and a kind and + * nothing else — the identifier is derived, and the French name and settings are editable * afterwards in the editor itself. + * + * The kind sits *inside* the composer rather than in front of it. Two buttons up front made + * the choice look like two separate features, and picking one swapped the whole row for a + * taller form, so the list below jumped every time. Here the panel opens once, at a fixed + * size, and the kind is a toggle you can still change while typing the name. */ import { useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; import { slugify, isValidIdentifier } from '../../../shared/project.ts'; import type { EntryKind } from '../../../shared/protocol.ts'; interface Props { + /** Shown with the derived id, so the resource location is visible before creating. */ + namespace: string; /** Called with the new entry's id, so the caller can select it. */ onCreated: (id: string) => void; + onCancel: () => void; onError: (message: string) => void; } -export function QuickNewEntry({ onCreated, onError }: Props) { +export function QuickNewEntry({ namespace, onCreated, onCancel, onError }: Props) { const { t } = useI18n(); - const [kind, setKind] = useState(null); + const [kind, setKind] = useState('block'); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const inputRef = useRef(null); - // Focus as soon as the field appears: this is a keyboard-speed path, not a form. - useEffect(() => { - if (kind) inputRef.current?.focus(); - }, [kind]); + // Focus on open: this is a keyboard-speed path, not a form to fill in. + useEffect(() => inputRef.current?.focus(), []); const id = slugify(name); const valid = name.trim().length > 0 && isValidIdentifier(id); - const close = (): void => { - setKind(null); - setName(''); - }; - const create = async (): Promise => { - if (!valid || !kind || busy) return; + if (!valid || busy) return; setBusy(true); const result = await window.ella.entries.create({ @@ -53,27 +55,23 @@ export function QuickNewEntry({ onCreated, onError }: Props) { onError(result.message); return; } - close(); onCreated(result.value.id); }; - if (!kind) { - return ( -
- - -
- ); - } - return ( -
-
- {kind === 'block' ? t('entry.newBlock') : t('entry.newItem')} +
+
+ {(['block', 'item'] as EntryKind[]).map((candidate) => ( + + ))}
setName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void create(); - if (event.key === 'Escape') close(); + if (event.key === 'Escape') onCancel(); }} /> - {/* Shows what the identifier will be, since it is derived rather than typed. */} - {name.trim().length > 0 && ( -
- {valid ? id : t('entry.idHelp')} -
- )} + {/* The identifier is derived, not typed, so the resource location the game will see + is spelled out before anything is written. */} +
+ {name.trim().length === 0 ? '' : valid ? `${namespace}:${id}` : t('entry.idHelp')} +
- -
diff --git a/launcher/src/renderer/src/components/SettingsForm.tsx b/launcher/src/renderer/src/components/SettingsForm.tsx index 618d9c3..3c59ebf 100644 --- a/launcher/src/renderer/src/components/SettingsForm.tsx +++ b/launcher/src/renderer/src/components/SettingsForm.tsx @@ -4,9 +4,18 @@ * Fields the running adapter cannot honour are disabled and labelled with the reason, * never hidden — someone testing on 1.8.9 should still be able to see that an option * exists, and why it is unavailable to them. + * + * The controls are driven by a local draft rather than by the saved project. Every change + * has to travel to the main process, be written to disk and be pushed to the running game + * before it comes back, and a control bound to the round trip does not follow the mouse — + * dragging a slider looked like the window had frozen. The draft answers the keyboard and + * mouse immediately; saving happens behind it, coalesced and never overlapping, with a + * visible indicator so "behind" never means "silently". */ +import { useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; import { fieldsFor, checkSettings, @@ -18,17 +27,29 @@ import { import type { EntryKind } from '../../../shared/protocol.ts'; interface Props { + /** Also the reset key: switching entry starts a fresh draft. */ + entryId: string; kind: EntryKind; settings: Record; capabilities: string[]; /** Null when no game is connected, so gating can be presented as unknown, not absent. */ minecraftVersion: string | null; - onChange: (patch: Record) => void; + /** Takes the entry id explicitly so a patch in flight cannot land on the wrong entry. */ + onChange: (entryId: string, patch: Record) => Promise; } const GROUPS: FieldGroup[] = ['appearance', 'physical', 'interaction']; +/** + * How long to wait after the last change before writing. + * + * Long enough that a slider drag is one write instead of forty, short enough that a + * checkbox still feels like it took effect at once. + */ +const DEBOUNCE_MS = 200; + export function SettingsForm({ + entryId, kind, settings, capabilities, @@ -36,25 +57,107 @@ export function SettingsForm({ onChange, }: Props) { const { t } = useI18n(); + const [draft, setDraft] = useState(settings); + const [saving, setSaving] = useState(false); + + /** What is waiting to be written, and for which entry — never assumed to be the current one. */ + const pending = useRef<{ entryId: string; patch: Record } | null>(null); + const timer = useRef | null>(null); + const flushing = useRef(false); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + // A different entry is a different draft. Keyed on the id and not on `settings`, because + // our own write comes back through `settings` and would otherwise overwrite whatever the + // user has moved since. + useEffect(() => { + setDraft(settings); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entryId]); + + const flush = async (): Promise => { + // One writer at a time. Anything that arrives mid-write is picked up by the loop + // below rather than racing the request already in flight. + if (flushing.current) return; + + flushing.current = true; + setSaving(true); + try { + while (pending.current) { + const { entryId: target, patch } = pending.current; + pending.current = null; + await onChangeRef.current(target, patch); + } + } finally { + flushing.current = false; + setSaving(false); + } + }; + + const commit = (patch: Record): void => { + setDraft((current) => ({ ...current, ...patch })); + + pending.current = + pending.current && pending.current.entryId === entryId + ? { entryId, patch: { ...pending.current.patch, ...patch } } + : { entryId, patch }; + + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => { + timer.current = null; + void flush(); + }, DEBOUNCE_MS); + }; + + // Leaving the entry, or the view, must not drop an edit still inside the debounce + // window. The pending patch carries its own entry id, so writing it late is safe. + useEffect(() => { + return () => { + if (timer.current) clearTimeout(timer.current); + if (pending.current) void flush(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entryId]); + const fields = fieldsFor(kind); - const warnings = checkSettings(kind, settings); + const warnings = checkSettings(kind, draft); + const busy = saving || pending.current !== null; const visible = (field: SettingField): boolean => - !field.visibleWhen || settings[field.visibleWhen.key] === field.visibleWhen.equals; + !field.visibleWhen || draft[field.visibleWhen.key] === field.visibleWhen.equals; return (
+
+

{t('entry.settings')}

+ + {/* Present only while there is something outstanding. A permanent "saved" chip + would be one more thing on screen that never changes. */} + {busy && ( + + + {t('common.saving')} + + )} +
+ {warnings.map((warning) => (
- {t(warning.messageKey)} - {warning.fix && ( - <> - {' '} - - - )} + +
+ {t(warning.messageKey)} + {warning.fix && ( + <> + {' '} + + + )} +
))} @@ -70,10 +173,10 @@ export function SettingsForm({ onChange({ [field.key]: value })} + onChange={(value) => commit({ [field.key]: value })} /> ))}
diff --git a/launcher/src/renderer/src/components/StatusBar.tsx b/launcher/src/renderer/src/components/StatusBar.tsx index d3f509f..5055bc3 100644 --- a/launcher/src/renderer/src/components/StatusBar.tsx +++ b/launcher/src/renderer/src/components/StatusBar.tsx @@ -1,4 +1,5 @@ import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; import type { SessionHook } from '../session.ts'; export function StatusBar({ session }: { session: SessionHook }) { @@ -11,40 +12,54 @@ export function StatusBar({ session }: { session: SessionHook }) { const usedSlots = project?.entries.filter((entry) => entry.slot !== null).length ?? 0; const totalSlots = state.game ? state.game.slots.block + state.game.slots.item - : ((project?.slotPool.block ?? 0) + (project?.slotPool.item ?? 0)); + : (project?.slotPool.block ?? 0) + (project?.slotPool.item ?? 0); return (
- + - {connected ? t('game.connected') : running ? t('versions.launching') : t('game.disconnected')} + {connected + ? t('game.connected') + : running + ? t('versions.launching') + : t('game.disconnected')} {state.game && ( - + + {state.game.minecraftVersion} · {state.game.loader} · {state.game.adapter} )} {project && ( - + {project.name} · {t('game.slotsUsed', { used: usedSlots, total: totalSlots })} )} + {/* The label matters more than the bar: "install" and "download" take very different + amounts of time, and a bare bar cannot say which one is moving. */} {progress && ( - -
-
-
- - )} - {progress && ( - - {progress.completed} / {progress.total} - + <> + + {progress.label ?? t(`progress.${progress.phase}`)} + + +
+
+
+ + + {progress.completed} / {progress.total} + + )}
); diff --git a/launcher/src/renderer/src/components/TexturePanel.tsx b/launcher/src/renderer/src/components/TexturePanel.tsx index 554543a..e14dd8b 100644 --- a/launcher/src/renderer/src/components/TexturePanel.tsx +++ b/launcher/src/renderer/src/components/TexturePanel.tsx @@ -13,7 +13,12 @@ import type { TextureVariableDto } from '../../../shared/ipc.ts'; interface Props { entryId: string; - onError: (message: string) => void; + /** + * Reports what went wrong, or null once it no longer applies. Every operation clears it + * first: a message about the last one, still on screen after the next one worked, is + * read as a description of the state rather than of a moment. + */ + onError: (message: string | null) => void; } export function TexturePanel({ entryId, onError }: Props) { @@ -41,6 +46,7 @@ export function TexturePanel({ entryId, onError }: Props) { action: () => Promise<{ ok: true; value: TextureVariableDto[] | null } | { ok: false; message: string }>, ): Promise => { setBusy(true); + onError(null); const result = await action(); setBusy(false); @@ -56,6 +62,7 @@ export function TexturePanel({ entryId, onError }: Props) { if (!key) return; setBusy(true); + onError(null); const result = await window.ella.entries.addTexture(entryId, key); setBusy(false); @@ -69,6 +76,7 @@ export function TexturePanel({ entryId, onError }: Props) { }; const remove = async (texture: TextureVariableDto): Promise => { + onError(null); // The image is kept: the variable is one line of JSON to restore, the artwork is not. const result = await window.ella.entries.removeTexture(entryId, texture.key, false); if (!result.ok) { @@ -77,6 +85,8 @@ export function TexturePanel({ entryId, onError }: Props) { } setTextures(result.value.textures); + // Not a failure — the removal worked — but the model will not load until those faces + // are pointed somewhere else, which is worth more than a toast that scrolls away. if (result.value.orphanedFaces.length > 0) { onError( t('texture.orphanedFaces', { diff --git a/launcher/src/renderer/src/components/Toast.tsx b/launcher/src/renderer/src/components/Toast.tsx new file mode 100644 index 0000000..c6b0066 --- /dev/null +++ b/launcher/src/renderer/src/components/Toast.tsx @@ -0,0 +1,187 @@ +/** + * Transient confirmations, failures, and the way back out of a change. + * + * Inline banners were the wrong place for the first two. They render where the action was + * triggered, which by the time a launch or an export finishes is often scrolled out of view + * or on another tab entirely — so a success looked like nothing happening, and a failure + * could be missed completely. + * + * Failures stay noticeably longer than confirmations and can be dismissed by hand: a message + * you have to read should not disappear while you are reading it. + * + * An undoable toast is the third kind, and the reason it lives here rather than in a dialog: + * a change that needs confirming *before* it happens interrupts the work, while one that can + * be taken back afterwards does not. Its remaining time is drawn as a thinning line, because + * an offer with a deadline should show the deadline rather than vanish mid-reach. + */ + +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; + +type ToastKind = 'ok' | 'error' | 'info'; + +/** What an undo attempt reports back. Shaped to accept an IPC `Result` unchanged. */ +type UndoOutcome = { ok: boolean; message?: string }; + +interface ToastEntry { + id: number; + kind: ToastKind; + message: string; + undo?: () => Promise; + /** Set while the undo is in flight, so the button cannot be pressed twice. */ + undoing?: boolean; +} + +const LIFETIME_MS: Record = { + ok: 4000, + info: 5000, + error: 10000, +}; + +/** + * Longer than a plain confirmation: this one is not just read, it is decided on, and the + * decision needs time to reach the mouse. + */ +const UNDO_LIFETIME_MS = 9000; + +const ICONS: Record = { + ok: 'check', + error: 'alert', + info: 'info', +}; + +export interface ToastApi { + ok: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + /** + * Reports a change and offers to reverse it. + * + * The offer expires with the toast, but nothing is destroyed when it does: what expires + * is the shortcut, never the possibility. + */ + undoable: (message: string, undo: () => Promise) => void; +} + +const noop: ToastApi = { + ok: () => {}, + error: () => {}, + info: () => {}, + undoable: () => {}, +}; + +const ToastContext = createContext(noop); + +export function ToastProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + + const dismiss = useCallback((id: number): void => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const push = useCallback( + (entry: Omit, lifetime: number): void => { + const id = nextId.current++; + setToasts((current) => [...current, { ...entry, id }]); + setTimeout(() => dismiss(id), lifetime); + }, + [dismiss], + ); + + /** + * Guards against a double press landing two calls in one frame, before the disabled + * state has rendered. The second would find the offer already spent and report an error + * for something that in fact worked. + */ + const running = useRef(new Set()); + + const runUndo = useCallback( + async (toast: ToastEntry): Promise => { + if (!toast.undo || running.current.has(toast.id)) return; + running.current.add(toast.id); + + setToasts((current) => + current.map((candidate) => + candidate.id === toast.id ? { ...candidate, undoing: true } : candidate, + ), + ); + + const result = await toast.undo(); + running.current.delete(toast.id); + dismiss(toast.id); + + // Replaced rather than left in place: the offer is spent either way, and a toast + // still showing "Undo" after one was attempted invites a second press. + push( + result.ok + ? { kind: 'ok', message: t('common.undone') } + : { kind: 'error', message: result.message ?? t('common.error') }, + result.ok ? LIFETIME_MS.ok : LIFETIME_MS.error, + ); + }, + [dismiss, push, t], + ); + + const api = useMemo( + () => ({ + ok: (message) => push({ kind: 'ok', message }, LIFETIME_MS.ok), + error: (message) => push({ kind: 'error', message }, LIFETIME_MS.error), + info: (message) => push({ kind: 'info', message }, LIFETIME_MS.info), + undoable: (message, undo) => push({ kind: 'ok', message, undo }, UNDO_LIFETIME_MS), + }), + [push], + ); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ +
{toast.message}
+ + {toast.undo && ( + + )} + + + + {/* Only where there is something to lose by waiting. */} + {toast.undo && ( + + )} +
+ ))} +
+
+ ); +} + +export const useToast = (): ToastApi => useContext(ToastContext); diff --git a/launcher/src/renderer/src/components/VersionChangeDialog.tsx b/launcher/src/renderer/src/components/VersionChangeDialog.tsx new file mode 100644 index 0000000..6437935 --- /dev/null +++ b/launcher/src/renderer/src/components/VersionChangeDialog.tsx @@ -0,0 +1,170 @@ +/** + * Confirmation for launching a project on a version it was not authored for. + * + * A project is bound to a version, and most of the time that binding is invisible: the + * launcher preselects it and the run matches. This dialog is the exception, and it exists + * because the ways a resource pack breaks across versions are all silent — the game loads + * the file, says nothing, and draws the wrong thing. Finding out from a black block in the + * world is far worse than being told here. + * + * So it lists what would actually break, per entry, rather than warning in the abstract; + * and where Ella can rewrite the file itself, it offers to. + */ + +import { useState } from 'react'; +import { useI18n } from '../i18n.tsx'; +import { Icon } from './Icon.tsx'; +import { ErrorBanner } from './ErrorBanner.tsx'; +import { versionChangeNotes, type VersionFacts } from '../../../shared/version-compat.ts'; +import type { Result, VersionChangePlanDto, VersionSummaryDto } from '../../../shared/ipc.ts'; + +interface Props { + plan: VersionChangePlanDto; + /** Installed versions, for the consequences that are not about the project's files. */ + versions: VersionSummaryDto[]; + projectName: string; + onCancel: () => void; + onConfirm: (adopt: boolean) => Promise>; +} + +export function VersionChangeDialog({ + plan, + versions, + projectName, + onCancel, + onConfirm, +}: Props) { + const { t } = useI18n(); + // Adopting is the answer that leaves the project consistent with its files, so it is the + // default. Unticking it makes the run a one-off, which is the rarer intent. + const [adopt, setAdopt] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const factsFor = (id: string | null): VersionFacts | null => { + if (!id) return null; + const summary = versions.find((version) => version.id === id); + return summary + ? { + id, + installed: summary.installed, + adapterStatus: summary.adapterStatus, + javaAvailable: summary.javaAvailable, + requiredJava: summary.requiredJava, + } + : null; + }; + + // A version absent from the installed list is one Ella cannot launch, which the notes + // then say in as many words rather than the dialog quietly showing nothing. + const target = factsFor(plan.to) ?? { + id: plan.to, + installed: false, + adapterStatus: null, + javaAvailable: true, + requiredJava: 0, + }; + + const notes = versionChangeNotes(factsFor(plan.from), target); + + const confirm = async (): Promise => { + setBusy(true); + const result = await onConfirm(adopt); + setBusy(false); + if (!result.ok) setError(result.message); + }; + + return ( +
+
event.stopPropagation()}> +
+
+
{t('compat.title')}
+
+ {t('compat.subtitle', { project: projectName, from: plan.from ?? '?', to: plan.to })} +
+
+ +
+ {plan.from} + + {plan.to} +
+
+ +
+ setError(null)} /> + + {notes.map((note) => ( +
+ +
{t(`compat.note.${note.id}`, note.detail)}
+
+ ))} + +

{t('compat.files')}

+ + {plan.findings.length === 0 ? ( +
+ +
{t('compat.noIssues', { to: plan.to })}
+
+ ) : ( +
+ {plan.findings.map((finding, index) => ( +
+ + {finding.entryId} + + {t(`compat.issue.${finding.issue}`, { ...finding.detail, to: plan.to })} + + {Number(finding.detail.count) > 1 && ( + {finding.detail.count} + )} + + + {finding.fixable ? t('compat.fixable') : t('compat.manual')} + +
+ ))} +
+ )} + +
+ {t('compat.automatic')} +
+
+ +
+ + + + + + +
+ + {/* Said last, next to the control it qualifies: unticking the box is not "cancel", + it is a run on another version with the project left where it is. */} + {!adopt && ( +
{t('compat.oneOff', { from: plan.from ?? '?' })}
+ )} +
+
+ ); +} diff --git a/launcher/src/renderer/src/facts.ts b/launcher/src/renderer/src/facts.ts new file mode 100644 index 0000000..e2de6b6 --- /dev/null +++ b/launcher/src/renderer/src/facts.ts @@ -0,0 +1,66 @@ +/** + * The facts the setup guide and the navigation both read. + * + * Gathered once in App and passed down rather than fetched per component: the installed + * version list is a disk scan, and three places asking for it independently would produce + * three different answers during an install. + */ + +import { useEffect, useState } from 'react'; +import type { WorkflowFacts } from '../../shared/workflow.ts'; +import type { VersionSummaryDto } from '../../shared/ipc.ts'; +import type { SessionHook } from './session.ts'; + +export interface Facts extends WorkflowFacts { + installed: VersionSummaryDto[]; + /** + * What a one-click launch should start: the open project's own version, then the last + * version launched if it is still installed, then the first one that can live-edit, then + * anything installed. + */ + preferredVersion: string | null; + /** Re-reads after an install, an uninstall, or a Blockbench path change. */ + refresh: () => void; +} + +export function useWorkflowFacts(session: SessionHook): Facts { + const [installed, setInstalled] = useState([]); + const [blockbenchFound, setBlockbenchFound] = useState(false); + const [lastVersion, setLastVersion] = useState(null); + const [nonce, setNonce] = useState(0); + + const { status } = session.state; + + useEffect(() => { + void window.ella.versions.installed().then((result) => { + if (result.ok) setInstalled(result.value); + }); + void window.ella.blockbench.resolve().then((path) => setBlockbenchFound(path !== null)); + void window.ella.config.get().then((config) => setLastVersion(config.lastVersion)); + // Launching writes lastVersion and an install changes the list, so the game's status + // is a good enough signal to re-read on without polling. + }, [status, nonce]); + + const liveCapable = installed.filter((version) => version.adapterStatus === 'built'); + + // The project's version outranks the last one launched: the guide's Launch button is a + // one-click path, and it should not be the thing that starts a version change. + const preferred = + installed.find((version) => version.id === session.project?.targetVersion) ?? + installed.find((version) => version.id === lastVersion) ?? + liveCapable[0] ?? + installed[0] ?? + null; + + return { + installed, + installedVersions: installed.length, + liveEditingVersions: liveCapable.length, + hasProject: session.project !== null, + entryCount: session.project?.entries.length ?? 0, + blockbenchFound, + connected: status === 'connected', + preferredVersion: preferred?.id ?? null, + refresh: () => setNonce((current) => current + 1), + }; +} diff --git a/launcher/src/renderer/src/main.tsx b/launcher/src/renderer/src/main.tsx index aa8acaa..c24b63e 100644 --- a/launcher/src/renderer/src/main.tsx +++ b/launcher/src/renderer/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { I18nProvider } from './i18n.tsx'; +import { ToastProvider } from './components/Toast.tsx'; import { App } from './App.tsx'; import './styles.css'; @@ -10,7 +11,9 @@ if (!container) throw new Error('Root element is missing from index.html'); createRoot(container).render( - + + + , ); diff --git a/launcher/src/renderer/src/navigation.ts b/launcher/src/renderer/src/navigation.ts new file mode 100644 index 0000000..8c83d30 --- /dev/null +++ b/launcher/src/renderer/src/navigation.ts @@ -0,0 +1,32 @@ +/** + * The view list, shared by App and by anything that needs to send the user somewhere. + * + * Kept out of App.tsx so the home view can navigate without importing its own parent. + */ + +import type { IconName } from './components/Icon.tsx'; + +export type View = 'home' | 'versions' | 'project' | 'editor' | 'logs' | 'export' | 'settings'; + +export interface NavItem { + id: View; + labelKey: string; + icon: IconName; + /** Nothing on this view works without a project open. */ + needsProject?: boolean; +} + +/* + * Ordered as the work is done, not alphabetically or by importance: home, then the setup + * (a version, a project), then the loop (editing, watching the log), then what comes at + * the end (export) and what you rarely touch (settings). + */ +export const NAV: NavItem[] = [ + { id: 'home', labelKey: 'nav.home', icon: 'home' }, + { id: 'versions', labelKey: 'nav.versions', icon: 'versions' }, + { id: 'project', labelKey: 'nav.project', icon: 'project' }, + { id: 'editor', labelKey: 'nav.editor', icon: 'editor', needsProject: true }, + { id: 'logs', labelKey: 'nav.logs', icon: 'logs' }, + { id: 'export', labelKey: 'nav.export', icon: 'export', needsProject: true }, + { id: 'settings', labelKey: 'nav.settings', icon: 'settings' }, +]; diff --git a/launcher/src/renderer/src/session.ts b/launcher/src/renderer/src/session.ts index f19dcc7..a66c648 100644 --- a/launcher/src/renderer/src/session.ts +++ b/launcher/src/renderer/src/session.ts @@ -5,7 +5,13 @@ import { useEffect, useState } from 'react'; import type { EllaProject } from '../../shared/project.ts'; import type { LogPayload } from '../../shared/protocol.ts'; -import type { ProgressDto, SessionStateDto, CrashDiagnosticsDto } from '../../shared/ipc.ts'; +import type { + ProgressDto, + Result, + SessionStateDto, + CrashDiagnosticsDto, + VersionChangePlanDto, +} from '../../shared/ipc.ts'; const EMPTY_STATE: SessionStateDto = { status: 'stopped', @@ -30,6 +36,7 @@ export function useSession() { const [lines, setLines] = useState([]); const [progress, setProgress] = useState(null); const [crash, setCrash] = useState(null); + const [versionChange, setVersionChange] = useState(null); useEffect(() => { let nextKey = 0; @@ -56,12 +63,69 @@ export function useSession() { return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); }, []); + // A pending version question belongs to the project that raised it. Keyed on the root + // rather than on the project object, which is replaced by every edit — including the + // rebind the question itself performs. + useEffect(() => { + setVersionChange(null); + }, [state.projectRoot]); + + /** + * The one way to start the game. + * + * Every launch goes through here so the version check cannot be bypassed by whichever + * button happens to be nearest — the sidebar, the versions list and the setup guide all + * start a run, and a guard on one of them is a guard on none. + * + * Resolves ok when the launch was deferred to the confirmation: a question is not a + * failure, and the dialog owns what happens next. + */ + const requestLaunch = async (versionId: string): Promise> => { + if (project) { + const plan = await window.ella.projects.planVersionChange(versionId); + if (plan.ok && plan.value.needsConfirmation) { + setVersionChange(plan.value); + return { ok: true, value: undefined }; + } + } + return window.ella.game.launch(versionId); + }; + + /** + * Answers the confirmation and launches. + * + * `adopt` rebinds the project to the new version and rewrites the models that need it; + * without it the run is a one-off and the project keeps pointing at the version it was + * authored for — so the same warning comes back next time, which is the point of binding + * a project to a version at all. + */ + const confirmVersionChange = async (adopt: boolean): Promise> => { + if (!versionChange) return { ok: true, value: undefined }; + const { to } = versionChange; + + // The rebind stands even if the launch then fails: moving the project was the answer + // given, and Java being missing does not retract it. The dialog stays open with the + // error, so the launch can be retried without being asked the same question again. + if (adopt) { + const applied = await window.ella.projects.applyVersionChange(to, true); + if (!applied.ok) return applied; + } + + const launched = await window.ella.game.launch(to); + if (launched.ok) setVersionChange(null); + return launched; + }; + return { state, project, lines, progress, crash, + versionChange, + requestLaunch, + confirmVersionChange, + cancelVersionChange: () => setVersionChange(null), dismissCrash: () => setCrash(null), clearLines: () => setLines([]), hasCapability: (capability: string) => state.capabilities.includes(capability), diff --git a/launcher/src/renderer/src/styles.css b/launcher/src/renderer/src/styles.css index 98641a5..98a31c6 100644 --- a/launcher/src/renderer/src/styles.css +++ b/launcher/src/renderer/src/styles.css @@ -1,17 +1,62 @@ +/* + * Ella's design system. + * + * Three rules hold the look together, and everything below follows from them: + * + * Depth by layer, not by line. Surfaces step up (--surface-1 → 3) as they come forward. + * Borders separate peers; elevation separates planes. + * + * One accent, spent carefully. Violet marks the thing to do next and nothing else, so a + * glance at any screen finds the primary action without reading. + * + * Motion only where it explains. Panels rise as they arrive because they came from + * somewhere; a live indicator pulses because it is live. Nothing animates for decoration, + * and all of it stops under prefers-reduced-motion. + */ + :root { - --bg: #16161c; - --bg-raised: #1e1e26; - --bg-input: #26262f; - --border: #33333f; - --text: #e6e6ec; - --text-dim: #9a9aab; - --text-faint: #6b6b7b; - --accent: #7c6cf5; - --accent-dim: #5b4fd0; - --ok: #3fbf7f; - --warn: #e0a02c; - --error: #e05252; - --radius: 6px; + /* Surfaces, back to front. */ + --bg: #0f0f14; + --surface-1: #16161d; + --surface-2: #1c1c26; + --surface-3: #23232f; + --surface-inset: #101015; + + --border: #2b2b38; + --border-soft: #22222d; + --border-strong: #3b3b4c; + + --text: #eeeef3; + --text-dim: #a4a4b8; + --text-faint: #6e6e84; + + --accent: #8b7bff; + --accent-bright: #a99bff; + --accent-deep: #6a58e8; + --accent-soft: rgba(139, 123, 255, 0.14); + --accent-line: rgba(139, 123, 255, 0.32); + + --ok: #45c98a; + --ok-soft: rgba(69, 201, 138, 0.13); + --warn: #e5a63a; + --warn-soft: rgba(229, 166, 58, 0.13); + --error: #ec5f5f; + --error-soft: rgba(236, 95, 95, 0.13); + + --radius-sm: 6px; + --radius: 9px; + --radius-lg: 14px; + --radius-pill: 999px; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-2: 0 6px 20px -6px rgba(0, 0, 0, 0.55); + --shadow-3: 0 24px 60px -12px rgba(0, 0, 0, 0.7); + --glow: 0 6px 22px -8px rgba(139, 123, 255, 0.75); + + --fast: 110ms; + --base: 180ms; + --slow: 300ms; + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); } * { @@ -20,118 +65,260 @@ body { margin: 0; - font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-family: 'Inter', 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif; font-size: 14px; + line-height: 1.5; color: var(--text); background: var(--bg); overflow: hidden; user-select: none; + /* Text at this size reads muddy without it on Windows. */ + -webkit-font-smoothing: antialiased; } +/* ---------------------------------------------------------------- controls */ + button { font: inherit; - color: inherit; - background: var(--bg-input); + font-weight: 500; + color: var(--text); + background: var(--surface-3); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 6px 12px; + border-radius: var(--radius-sm); + padding: 6px 13px; cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + transition: + background var(--fast) var(--ease), + border-color var(--fast) var(--ease), + transform var(--fast) var(--ease), + box-shadow var(--fast) var(--ease); } button:hover:not(:disabled) { - border-color: var(--accent); + background: var(--border); + border-color: var(--border-strong); +} +button:active:not(:disabled) { + transform: translateY(1px); } button:disabled { - opacity: 0.4; + opacity: 0.42; cursor: not-allowed; } + button.primary { - background: var(--accent); - border-color: var(--accent); + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-deep) 100%); + border-color: var(--accent-deep); color: #fff; + box-shadow: var(--shadow-1); } button.primary:hover:not(:disabled) { - background: var(--accent-dim); + background: linear-gradient(180deg, var(--accent-bright) 0%, var(--accent) 100%); + border-color: var(--accent); + box-shadow: var(--glow); } + +button.subtle { + background: transparent; + border-color: transparent; + color: var(--text-dim); +} +button.subtle:hover:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} + button.danger:hover:not(:disabled) { border-color: var(--error); + background: var(--error-soft); color: var(--error); } +button.link { + background: none; + border: none; + padding: 0; + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} +button.link:hover:not(:disabled) { + color: var(--accent-bright); +} + +/* Square button holding an icon alone. */ +button.icon-only { + padding: 6px; + aspect-ratio: 1; +} + +button svg:not(:only-child) { + margin-right: -1px; +} + input, -select { +select, +textarea { font: inherit; color: inherit; - background: var(--bg-input); + background: var(--surface-inset); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 5px 8px; + border-radius: var(--radius-sm); + padding: 6px 9px; width: 100%; user-select: text; + transition: + border-color var(--fast) var(--ease), + box-shadow var(--fast) var(--ease); +} +input::placeholder { + color: var(--text-faint); +} +input:hover:not(:disabled), +select:hover:not(:disabled) { + border-color: var(--border-strong); } input:focus, -select:focus { +select:focus, +textarea:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } input[type='checkbox'] { width: auto; accent-color: var(--accent); + cursor: pointer; } input[type='range'] { padding: 0; accent-color: var(--accent); + background: none; + border: none; + cursor: pointer; +} +input[type='range']:focus { + box-shadow: none; +} + +/* Keyboard users get a ring on everything; mouse users never see it. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button:focus-visible { + outline-offset: 1px; +} + +/* Scrollbars, matched to the surfaces they sit on. */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border: 3px solid transparent; + background-clip: content-box; + border-radius: var(--radius-pill); +} +::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); + background-clip: content-box; } /* ---------------------------------------------------------------- layout */ .app { display: grid; - grid-template-columns: 190px 1fr; + grid-template-columns: 218px 1fr; grid-template-rows: 1fr auto; height: 100vh; + /* A single light source behind the whole app; the sidebar and cards read as lit from + the top left rather than as flat blocks of colour. */ + background: + radial-gradient(1100px 620px at 8% -8%, rgba(139, 123, 255, 0.09), transparent 60%), + var(--bg); } .sidebar { grid-row: 1 / 3; - background: var(--bg-raised); - border-right: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface-1) 0%, rgba(18, 18, 24, 0.92) 100%); + border-right: 1px solid var(--border-soft); display: flex; flex-direction: column; - padding: 14px 10px; - gap: 4px; + padding: 16px 12px 12px; + gap: 2px; + z-index: 1; } .brand { - font-size: 18px; - font-weight: 600; - padding: 4px 8px 14px; - letter-spacing: 0.4px; + display: flex; + align-items: center; + gap: 9px; + font-size: 17px; + font-weight: 650; + letter-spacing: 0.2px; + padding: 2px 6px 16px; +} +.brand-mark { + width: 26px; + height: 26px; + border-radius: 8px; + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + display: grid; + place-items: center; + color: #fff; + box-shadow: var(--glow); + flex: 0 0 auto; } -.brand span { - color: var(--accent); +.brand-text span { + color: var(--accent-bright); +} + +/* Two steps quieter than the wordmark: the version is there to be read once, not to share + billing with the name. Selectable, because its whole purpose is being copied into a + bug report. */ +.brand-version { + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-faint); + margin-left: -3px; + user-select: text; } .project-chip { display: block; width: 100%; text-align: left; - background: var(--bg-input); + background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); - padding: 7px 10px; - margin-bottom: 12px; + padding: 8px 11px; + margin-bottom: 14px; line-height: 1.35; } .project-chip:hover { - border-color: var(--accent); + background: var(--surface-3); + border-color: var(--accent-line); } -.project-chip.empty { +/* Not `.empty`: that class is the full-page empty state, and its 40px padding and centred + text were landing on this button too. */ +.project-chip.vacant { border-style: dashed; + color: var(--text-faint); } .project-chip-name { display: block; - font-weight: 500; + font-weight: 550; /* Long project names must not widen the sidebar. */ overflow: hidden; text-overflow: ellipsis; @@ -150,60 +337,116 @@ input[type='range'] { .nav-item { display: flex; align-items: center; - gap: 9px; + gap: 10px; + width: 100%; text-align: left; + justify-content: flex-start; background: none; border: none; - padding: 8px 10px; - border-radius: var(--radius); + padding: 8px 11px; + border-radius: var(--radius-sm); color: var(--text-dim); + font-weight: 500; + position: relative; } - -/* Icons sit slightly dimmer than their label until the row is active, so the text stays - the thing you read first. */ +.nav-item:hover:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} +.nav-item.active { + background: var(--accent-soft); + color: var(--text); +} +/* The active marker is drawn rather than bordered so it can round its own ends. */ +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 17px; + border-radius: var(--radius-pill); + background: var(--accent-bright); +} + +/* Icons sit dimmer than their label until the row is active, so the text stays the thing + you read first. */ .nav-item svg { - opacity: 0.7; + opacity: 0.75; flex: 0 0 auto; } -.nav-item.active svg, +.nav-item.active svg { + opacity: 1; + color: var(--accent-bright); +} .nav-item:hover svg { opacity: 1; } -/* Any button holding an icon and a label lines them up the same way. */ -button svg { - vertical-align: -3px; +.nav-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; } -button svg:not(:only-child) { - margin-right: 6px; + +/* Count or state for the view behind the row: how many versions are installed, whether + the game is live. Reading it should not need opening the tab. */ +.nav-badge { + font-size: 10.5px; + font-weight: 600; + min-width: 19px; + height: 18px; + padding: 0 6px; + border-radius: var(--radius-pill); + background: var(--surface-3); + color: var(--text-faint); + display: grid; + place-items: center; + flex: 0 0 auto; } -.nav-item:hover { - background: var(--bg-input); - color: var(--text); +.nav-item.active .nav-badge { + background: rgba(139, 123, 255, 0.22); + color: var(--accent-bright); } -.nav-item.active { - background: var(--bg-input); - color: var(--text); - box-shadow: inset 2px 0 0 var(--accent); +.nav-badge.live { + background: var(--ok-soft); + color: var(--ok); +} +.nav-badge.attention { + background: var(--accent); + color: #fff; } -/* Pushes quick launch to the bottom of the sidebar whatever the nav length. */ .sidebar-spacer { flex: 1; min-height: 12px; } +/* ---------------------------------------------------------------- quick launch */ + .quick-launch { - border-top: 1px solid var(--border); - padding-top: 10px; + border-top: 1px solid var(--border-soft); + padding-top: 12px; display: flex; flex-direction: column; + gap: 7px; +} + +.quick-launch-label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.7px; + text-transform: uppercase; + color: var(--text-faint); + display: flex; + align-items: center; gap: 6px; } .quick-launch select { font-size: 13px; - padding: 5px 6px; + padding: 6px 7px; } .quick-launch button { @@ -213,7 +456,14 @@ button svg:not(:only-child) { .quick-launch-note { font-size: 11px; color: var(--text-faint); - line-height: 1.35; + line-height: 1.4; + display: flex; + gap: 5px; + align-items: flex-start; +} +.quick-launch-note svg { + flex: 0 0 auto; + margin-top: 1px; } .quick-launch-note.error { color: var(--error); @@ -222,7 +472,7 @@ button svg:not(:only-child) { .quick-launch-error { font-size: 11px; color: var(--error); - line-height: 1.35; + line-height: 1.4; /* An error here can be a long path or stack line; keep it from stretching the sidebar. */ max-height: 60px; overflow-y: auto; @@ -232,59 +482,56 @@ button svg:not(:only-child) { .quick-launch-empty { font-size: 11px; color: var(--text-faint); - line-height: 1.4; + line-height: 1.45; } +/* ---------------------------------------------------------------- content */ + .content { overflow-y: auto; - padding: 22px 26px; + padding: 26px 30px 34px; } -.statusbar { - grid-column: 2; - border-top: 1px solid var(--border); - background: var(--bg-raised); - padding: 6px 14px; - display: flex; - align-items: center; - gap: 14px; - font-size: 12px; - color: var(--text-dim); +/* Every view arrives the same way, so switching tabs reads as movement rather than as a + repaint. */ +.view { + max-width: 1100px; + animation: view-in var(--slow) var(--ease); } -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - margin-right: 6px; - background: var(--text-faint); +@keyframes view-in { + from { + opacity: 0; + transform: translateY(6px); + } } -.dot.connected { - background: var(--ok); + +.page-head { + margin-bottom: 22px; } -.dot.running { - background: var(--warn); +.page-head .row { + align-items: flex-start; } -/* ---------------------------------------------------------------- pieces */ - h1 { - font-size: 20px; - margin: 0 0 4px; - font-weight: 600; + font-size: 23px; + line-height: 1.25; + margin: 0 0 5px; + font-weight: 620; + letter-spacing: -0.3px; } h2 { - font-size: 14px; - margin: 22px 0 10px; - color: var(--text-dim); - font-weight: 600; + font-size: 11.5px; + margin: 26px 0 11px; + color: var(--text-faint); + font-weight: 650; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0.9px; } .subtitle { color: var(--text-dim); - margin: 0 0 18px; + margin: 0; + max-width: 68ch; } .row { @@ -296,12 +543,35 @@ h2 { flex: 1; } +/* ---------------------------------------------------------------- surfaces */ + .card { - background: var(--bg-raised); - border: 1px solid var(--border); + background: var(--surface-1); + border: 1px solid var(--border-soft); border-radius: var(--radius); - padding: 12px 14px; - margin-bottom: 8px; + padding: 14px 16px; + margin-bottom: 10px; +} + +.card.interactive { + cursor: pointer; + transition: + border-color var(--base) var(--ease), + background var(--base) var(--ease), + transform var(--base) var(--ease); +} +.card.interactive:hover { + border-color: var(--accent-line); + background: var(--surface-2); + transform: translateY(-1px); +} + +.card .name { + font-weight: 550; +} +.card .meta { + color: var(--text-faint); + font-size: 12px; } .list { @@ -314,17 +584,22 @@ h2 { display: flex; align-items: center; gap: 12px; - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 9px 12px; + background: var(--surface-1); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + padding: 10px 13px; cursor: pointer; + transition: + border-color var(--fast) var(--ease), + background var(--fast) var(--ease); } .list-row:hover { - border-color: var(--accent-dim); + background: var(--surface-2); + border-color: var(--border); } .list-row.selected { border-color: var(--accent); + background: var(--accent-soft); } .list-row .name { font-weight: 500; @@ -336,47 +611,79 @@ h2 { .badge { font-size: 11px; - padding: 2px 7px; - border-radius: 10px; + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius-pill); border: 1px solid var(--border); + background: var(--surface-2); color: var(--text-dim); + white-space: nowrap; + flex: 0 0 auto; } .badge.ok { color: var(--ok); - border-color: var(--ok); + border-color: transparent; + background: var(--ok-soft); } .badge.warn { color: var(--warn); - border-color: var(--warn); + border-color: transparent; + background: var(--warn-soft); } .badge.error { color: var(--error); - border-color: var(--error); + border-color: transparent; + background: var(--error-soft); +} +.badge.accent { + color: var(--accent-bright); + border-color: transparent; + background: var(--accent-soft); } +.divider { + height: 1px; + background: var(--border-soft); + margin: 18px 0; +} + +/* ---------------------------------------------------------------- fields */ + .field { - margin-bottom: 12px; + margin-bottom: 14px; } .field label { display: block; - margin-bottom: 4px; + margin-bottom: 5px; color: var(--text-dim); font-size: 12px; + font-weight: 500; } -.field .help { +.field .help, +.help { color: var(--text-faint); - font-size: 11px; - margin-top: 3px; - line-height: 1.4; + font-size: 11.5px; + margin-top: 4px; + line-height: 1.45; } .field.disabled { opacity: 0.45; } +/* Explanatory line under a section heading. Sized in pixels rather than `ch` because at + this font size a `ch` budget produces a column far narrower than it reads as. */ +.section-note { + color: var(--text-faint); + font-size: 12.5px; + line-height: 1.55; + max-width: 640px; + margin: -4px 0 12px; +} + .field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0 18px; + gap: 0 20px; } .inline { @@ -385,114 +692,581 @@ h2 { gap: 8px; } +/* ---------------------------------------------------------------- messages */ + .warning { + display: flex; + gap: 10px; + align-items: flex-start; + border: 1px solid var(--warn-soft); border-left: 3px solid var(--warn); - background: rgba(224, 160, 44, 0.08); - padding: 9px 12px; - border-radius: 0 var(--radius) var(--radius) 0; - margin-bottom: 8px; + background: var(--warn-soft); + padding: 10px 13px; + border-radius: var(--radius-sm); + margin-bottom: 10px; font-size: 13px; line-height: 1.5; } +.warning svg { + flex: 0 0 auto; + margin-top: 2px; + color: var(--warn); +} .warning.error { - border-color: var(--error); - background: rgba(224, 82, 82, 0.08); + border-color: var(--error-soft); + border-left-color: var(--error); + background: var(--error-soft); } -.empty { +/* Pushed to the right edge so the text keeps the full width, and quiet until reached for: + the message is what matters, the way out is what you look for second. */ +.warning-close { + margin-left: auto; + flex: 0 0 auto; + background: none; + border: none; + padding: 1px; color: var(--text-faint); - text-align: center; - padding: 40px 0; } - -.split { - display: grid; - grid-template-columns: 280px 1fr; - gap: 20px; - align-items: start; -} - -.console { - background: #0f0f14; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 10px; - font-family: 'Cascadia Mono', Consolas, monospace; - font-size: 11px; - line-height: 1.5; - height: 220px; - overflow-y: auto; - user-select: text; - white-space: pre-wrap; - word-break: break-all; +.warning-close:hover { + background: none; + color: var(--text); } -.console .warn { - color: var(--warn); +/* Outranks the tone rules below, which colour every icon inside a banner. */ +.warning button.warning-close svg { + margin-top: 0; + color: inherit; } -.console .error { +.warning.error svg { color: var(--error); } - -.progress { - height: 4px; - background: var(--bg-input); - border-radius: 2px; - overflow: hidden; +.warning.info { + border-color: var(--accent-soft); + border-left-color: var(--accent); + background: var(--accent-soft); } -.progress > div { - height: 100%; - background: var(--accent); - transition: width 0.15s; +.warning.info svg { + color: var(--accent-bright); } -.entry-identity { +/* Empty is a place to act from, not a dead end: an icon to orient, a line saying what is + missing, and the button that fixes it. */ +.empty-state { display: flex; - gap: 16px; - align-items: flex-start; - margin-bottom: 4px; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; + padding: 46px 24px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.012); } -.entry-identity-preview { - flex: 0 0 auto; - width: 132px; - height: 132px; - border: 1px solid var(--border); +.empty-state-icon { + width: 46px; + height: 46px; border-radius: var(--radius); - background: var(--bg-raised); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; + display: grid; + place-items: center; + background: var(--surface-2); + color: var(--text-faint); + margin-bottom: 10px; } -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 10px; +.empty-state-title { + font-weight: 550; + font-size: 15px; } -.entry-card { - display: block; - text-align: center; - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 10px 8px; +.empty-state-text { + color: var(--text-faint); + font-size: 13px; + max-width: 46ch; + line-height: 1.5; } -.entry-card:hover { - border-color: var(--accent); + +.empty-state button { + margin-top: 14px; } -.entry-card-preview { +/* Kept for the few places that want a bare line rather than the full panel. */ +.empty { + color: var(--text-faint); + text-align: center; + padding: 40px 0; +} + +/* ---------------------------------------------------------------- guide */ + +/* + * The setup guide: an ordered list of what has to be true before live editing works, each + * step reading its own state off the session rather than being ticked by hand. Steps are + * never hidden once done — seeing the whole path is what makes the order obvious. + */ +.guide { + display: flex; + flex-direction: column; + /* Narrower than the page: the action button belongs beside the step it acts on, not + stranded against the far edge of a wide window. */ + max-width: 780px; +} + +.guide-step { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 14px; + align-items: start; + padding: 13px 4px; + position: relative; +} +/* The rail joins the step markers, so the list reads as one path rather than five cards. */ +.guide-step:not(:last-child)::before { + content: ''; + position: absolute; + left: 14px; + top: 40px; + bottom: -6px; + width: 2px; + background: var(--border); + border-radius: var(--radius-pill); +} +.guide-step.done:not(:last-child)::before { + background: rgba(69, 201, 138, 0.35); +} + +.guide-marker { + width: 30px; + height: 30px; + border-radius: var(--radius-pill); + display: grid; + place-items: center; + font-size: 12.5px; + font-weight: 650; + border: 1.5px solid var(--border); + background: var(--surface-2); + color: var(--text-faint); + z-index: 1; + transition: + background var(--base) var(--ease), + border-color var(--base) var(--ease), + color var(--base) var(--ease); +} +.guide-step.done .guide-marker { + background: var(--ok-soft); + border-color: transparent; + color: var(--ok); +} +.guide-step.current .guide-marker { + background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep)); + border-color: transparent; + color: #fff; + box-shadow: var(--glow); +} + +.guide-title { + font-weight: 550; + padding-top: 4px; +} +.guide-step.done .guide-title { + color: var(--text-dim); +} + +.guide-why { + color: var(--text-faint); + font-size: 12.5px; + margin-top: 2px; + line-height: 1.5; + max-width: 58ch; +} + +.guide-step .guide-action { + margin-top: 2px; +} +/* Only the step you are on gets the accent; the rest stay quiet so there is exactly one + obvious thing to click. */ +.guide-step.todo .guide-action { + opacity: 0.55; +} + +/* ---------------------------------------------------------------- home */ + +.hero { + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + background: + radial-gradient(620px 220px at 12% 0%, rgba(139, 123, 255, 0.16), transparent 70%), + var(--surface-1); + padding: 24px 26px; + margin-bottom: 20px; +} + +.hero h1 { + font-size: 26px; +} + +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 10px; + margin-top: 20px; +} + +.stat { + background: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 11px 13px; +} + +.stat-value { + font-size: 19px; + font-weight: 620; + letter-spacing: -0.3px; + line-height: 1.2; + display: flex; + align-items: center; + gap: 7px; +} +.stat-value.ok { + color: var(--ok); +} +.stat-value.dim { + color: var(--text-faint); +} + +.stat-label { + font-size: 11px; + color: var(--text-faint); + margin-top: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 550; +} + +.action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 10px; +} + +.action-card { + display: flex; + gap: 12px; + align-items: flex-start; + text-align: left; + justify-content: flex-start; + background: var(--surface-1); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 14px; + white-space: normal; +} +.action-card:hover:not(:disabled) { + background: var(--surface-2); + border-color: var(--accent-line); + transform: translateY(-1px); +} + +.action-card-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + background: var(--accent-soft); + color: var(--accent-bright); + flex: 0 0 auto; +} + +/* A project name is user-supplied and can be any length; it must not stretch the grid. */ +.action-card-title { + display: block; + font-weight: 550; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action-card-text { + font-size: 12px; + color: var(--text-faint); + font-weight: 400; + line-height: 1.45; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------------------------------------------------------------- status bar */ + +.statusbar { + grid-column: 2; + border-top: 1px solid var(--border-soft); + background: var(--surface-1); + padding: 7px 16px; + display: flex; + align-items: center; + gap: 16px; + font-size: 12px; + color: var(--text-dim); +} + +.statusbar-item { + display: flex; + align-items: center; + gap: 7px; + white-space: nowrap; +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; + background: var(--text-faint); + flex: 0 0 auto; +} +.dot.connected { + background: var(--ok); + /* Live, and the only thing on screen that moves on its own. */ + animation: pulse 2.4s var(--ease) infinite; +} +.dot.running { + background: var(--warn); + animation: pulse 1.1s var(--ease) infinite; +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 currentColor; + opacity: 1; + } + 50% { + box-shadow: 0 0 0 4px transparent; + opacity: 0.55; + } +} + +.progress { + height: 4px; + background: var(--surface-3); + border-radius: var(--radius-pill); + overflow: hidden; +} +.progress > div { + height: 100%; + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--accent), var(--accent-bright)); + transition: width var(--base) var(--ease); +} + +/* ---------------------------------------------------------------- toasts */ + +/* + * Confirmations and failures both land here. Inline banners were being missed: they render + * wherever the action happened, which is often scrolled out of view by the time it + * finishes. + */ +.toast-stack { + position: fixed; + right: 18px; + bottom: 52px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; + pointer-events: none; +} + +.toast { + display: flex; + gap: 11px; + align-items: flex-start; + min-width: 260px; + max-width: 400px; + padding: 11px 13px; + border-radius: var(--radius); + background: var(--surface-3); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-3); + pointer-events: auto; + animation: toast-in var(--base) var(--ease); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(14px) scale(0.97); + } +} + +.toast.ok { + border-color: rgba(69, 201, 138, 0.4); +} +.toast.error { + border-color: rgba(236, 95, 95, 0.45); +} + +.toast-icon { + flex: 0 0 auto; + margin-top: 1px; +} +.toast.ok .toast-icon { + color: var(--ok); +} +.toast.error .toast-icon { + color: var(--error); +} +.toast.info .toast-icon { + color: var(--accent-bright); +} + +.toast-body { + flex: 1; + font-size: 13px; + line-height: 1.45; + user-select: text; + word-break: break-word; +} + +.toast-close { + background: none; + border: none; + padding: 2px; + color: var(--text-faint); + flex: 0 0 auto; +} +.toast-close:hover { + color: var(--text); + background: none; +} + +/* The one action in a toast, so it is the one thing in it that reads as pressable. */ +.toast-undo { + flex: 0 0 auto; + padding: 3px 10px; + font-size: 12px; + font-weight: 600; + color: var(--accent-bright); + background: var(--accent-soft); + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); +} +.toast-undo:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +/* The remaining time, drawn rather than left to be guessed at. A toast that can be acted + on and simply disappears reads as one that was missed. */ +.toast:has(.toast-undo) { + position: relative; + overflow: hidden; +} + +.toast-life { + position: absolute; + left: 0; + bottom: 0; + height: 2px; + width: 100%; + transform-origin: left; + background: var(--accent-line); + animation: toast-life linear forwards; +} + +@keyframes toast-life { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} + +@media (prefers-reduced-motion: reduce) { + .toast-life { + display: none; + } +} + +/* ---------------------------------------------------------------- entries */ + +.split { + display: grid; + grid-template-columns: 272px 1fr; + gap: 24px; + align-items: start; +} + +.entry-identity { + display: flex; + gap: 18px; + align-items: flex-start; + margin-bottom: 4px; +} + +.entry-identity-preview { + flex: 0 0 auto; + width: 132px; + height: 132px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: + radial-gradient(90px 90px at 50% 38%, rgba(139, 123, 255, 0.1), transparent 70%), + var(--surface-1); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +/* Turnable previews: the cursor is the only affordance telling you the model can be + dragged, and touch-action keeps a drag from scrolling the panel instead of rotating. */ +.model-preview-turnable { + cursor: grab; + touch-action: none; + outline-offset: -2px; +} + +.model-preview-turnable:active { + cursor: grabbing; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: 10px; +} + +.entry-card { + display: block; + text-align: center; + background: var(--surface-1); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px 10px 10px; + position: relative; + transition: + border-color var(--base) var(--ease), + background var(--base) var(--ease), + transform var(--base) var(--ease); +} +.entry-card:hover { + border-color: var(--accent-line); + background: var(--surface-2); + transform: translateY(-2px); +} + +.entry-card-preview { display: flex; align-items: center; justify-content: center; height: 112px; - margin-bottom: 6px; + margin-bottom: 8px; } .entry-card-name { - font-weight: 500; + font-weight: 550; font-size: 13px; overflow: hidden; text-overflow: ellipsis; @@ -505,14 +1279,31 @@ h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-top: 1px; } +/* Kind marker in the corner of a card: which of the two things this is, without spending + a line of text on it. */ +.entry-card-kind { + position: absolute; + top: 8px; + left: 8px; + color: var(--text-faint); + opacity: 0.6; +} +.entry-card:hover .entry-card-kind { + opacity: 1; + color: var(--accent-bright); +} + +/* ---------------------------------------------------------------- textures */ + .texture-preview { width: 96px; height: 96px; flex: 0 0 auto; border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: var(--radius-sm); /* Checkerboard behind the image so transparent pixels read as transparent rather than as the panel background. */ background-image: @@ -561,26 +1352,56 @@ h2 { display: flex; gap: 12px; align-items: center; - padding: 8px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); + padding: 10px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--surface-1); } .texture-key { font-size: 13px; - color: var(--accent); + font-weight: 550; + color: var(--accent-bright); } -button.link { - background: none; - border: none; - padding: 0; - color: var(--accent); - text-decoration: underline; +/* ---------------------------------------------------------------- console */ + +.console { + background: var(--surface-inset); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 12px; + font-family: 'Cascadia Mono', 'JetBrains Mono', Consolas, monospace; + font-size: 11.5px; + line-height: 1.6; + height: 220px; + overflow-y: auto; + user-select: text; + white-space: pre-wrap; + word-break: break-all; } -button.link:hover:not(:disabled) { - color: var(--text); +.console .warn { + color: var(--warn); +} +.console .error { + color: var(--error); +} + +.crash-console { + height: 300px; +} + +/* The logs view is the whole page, so the console grows to fill it rather than sitting in + a fixed box. */ +.logs-console { + height: calc(100vh - 268px); + min-height: 240px; +} + +.scroll-list { + max-height: 460px; + overflow-y: auto; + padding-right: 4px; } /* ---------------------------------------------------------------- modal */ @@ -588,31 +1409,47 @@ button.link:hover:not(:disabled) { .modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: rgba(6, 6, 9, 0.7); + backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; - padding: 32px; + padding: 34px; z-index: 100; + animation: fade-in var(--base) var(--ease); +} + +@keyframes fade-in { + from { + opacity: 0; + } } .modal { - background: var(--bg-raised); + background: var(--surface-1); border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: var(--radius-lg); width: min(900px, 100%); max-height: 100%; display: flex; flex-direction: column; - box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-3); + animation: modal-in var(--base) var(--ease); +} + +@keyframes modal-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.99); + } } .modal-header { display: flex; align-items: flex-start; gap: 16px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); + padding: 16px 18px; + border-bottom: 1px solid var(--border-soft); } .modal-title { @@ -630,7 +1467,7 @@ button.link:hover:not(:disabled) { } .modal-body { - padding: 14px 16px; + padding: 16px 18px; overflow-y: auto; flex: 1; min-height: 0; @@ -640,16 +1477,63 @@ button.link:hover:not(:disabled) { display: flex; align-items: center; gap: 10px; - padding: 12px 16px; - border-top: 1px solid var(--border); + padding: 13px 18px; + border-top: 1px solid var(--border-soft); +} + +/* The version dialog carries far less than a crash report; a full-width sheet for a + handful of rows would read as an alarm out of proportion to what it is. */ +.modal.narrow { + width: min(640px, 100%); +} + +/* The crash subtitle is a fault; a version change is not, and painting it in the error + colour would make a routine confirmation look like something went wrong. */ +.modal-subtitle.plain { + color: var(--text-dim); +} + +.modal-footer-note { + padding: 0 18px 13px; + color: var(--text-dim); + font-size: 12px; +} + +.version-swap { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--text-dim); + white-space: nowrap; +} + +.version-swap-to { + color: var(--text); + font-weight: 550; +} + +.compat-heading { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + margin: 14px 0 8px; +} + +/* Wraps rather than truncating: the sentence is the whole reason the row is there. */ +.compat-detail { + color: var(--text-dim); + font-size: 12px; + min-width: 0; } .crash-env { display: grid; grid-template-columns: max-content 1fr; - gap: 3px 14px; + gap: 4px 16px; font-size: 12px; - margin-bottom: 14px; + margin-bottom: 16px; user-select: text; } @@ -667,10 +1551,12 @@ button.link:hover:not(:disabled) { word-break: break-all; } +/* ---------------------------------------------------------------- tabs */ + .tabs { display: flex; - gap: 4px; - margin-bottom: 8px; + gap: 3px; + margin-bottom: 10px; } .tab { @@ -678,10 +1564,12 @@ button.link:hover:not(:disabled) { border: none; border-bottom: 2px solid transparent; border-radius: 0; - padding: 5px 10px; + padding: 6px 11px; color: var(--text-dim); + font-weight: 500; } .tab:hover:not(:disabled) { + background: none; color: var(--text); } .tab.active { @@ -689,19 +1577,95 @@ button.link:hover:not(:disabled) { border-bottom-color: var(--accent); } -.crash-console { - height: 300px; +/* Segmented control: for switching how the same data is shown, where tabs would imply + different content. */ +.segmented { + display: inline-flex; + padding: 2px; + gap: 2px; + background: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); +} +.segmented button { + background: none; + border: none; + padding: 4px 11px; + font-size: 12.5px; + color: var(--text-faint); + border-radius: 5px; +} +.segmented button:hover:not(:disabled) { + background: var(--surface-3); + color: var(--text); +} +.segmented button.active { + background: var(--surface-3); + color: var(--text); + box-shadow: var(--shadow-1); } -/* The logs view is the whole page, so the console grows to fill it rather than - sitting in a fixed box. */ -.logs-console { - height: calc(100vh - 260px); - min-height: 240px; +/* Splits the available width evenly, for a choice that is the panel's own first question + rather than a control tucked beside a heading. */ +.segmented.fill { + display: flex; + width: 100%; +} +.segmented.fill button { + flex: 1; + min-width: 0; } -.scroll-list { - max-height: 460px; - overflow-y: auto; - padding-right: 4px; +/* Shown while a settings change is on its way to disk and to the game. The controls stay + live behind it — this reports progress, it does not gate anything. */ +.save-indicator { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 11.5px; + font-weight: 550; + color: var(--accent-bright); + background: var(--accent-soft); + border-radius: var(--radius-pill); + padding: 3px 11px 3px 8px; + animation: fade-in var(--fast) var(--ease); +} + +.spinner { + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid rgba(169, 155, 255, 0.28); + border-top-color: var(--accent-bright); + animation: spin 700ms linear infinite; + flex: 0 0 auto; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Keyboard shortcut hint, e.g. in the navigation tooltips. */ +.kbd { + font-family: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--text-faint); + background: var(--surface-3); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + padding: 1px 5px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } diff --git a/launcher/src/renderer/src/versions.ts b/launcher/src/renderer/src/versions.ts new file mode 100644 index 0000000..4b65ad4 --- /dev/null +++ b/launcher/src/renderer/src/versions.ts @@ -0,0 +1,22 @@ +/** + * Installed versions, for the forms that need to offer a choice of one. + * + * Read from disk rather than the manifest, so it is instant and works offline — and read + * once per mounted form rather than held globally: these are short-lived panels, and a + * version installed while one of them is open is not a case worth complicating them for. + */ + +import { useEffect, useState } from 'react'; +import type { VersionSummaryDto } from '../../shared/ipc.ts'; + +export function useInstalledVersions(): VersionSummaryDto[] { + const [versions, setVersions] = useState([]); + + useEffect(() => { + void window.ella.versions.installed().then((result) => { + if (result.ok) setVersions(result.value); + }); + }, []); + + return versions; +} diff --git a/launcher/src/renderer/src/views/EditorView.tsx b/launcher/src/renderer/src/views/EditorView.tsx index 70f6541..71e90d7 100644 --- a/launcher/src/renderer/src/views/EditorView.tsx +++ b/launcher/src/renderer/src/views/EditorView.tsx @@ -1,31 +1,50 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Icon } from '../components/Icon.tsx'; +import { EmptyState } from '../components/EmptyState.tsx'; +import { ErrorBanner } from '../components/ErrorBanner.tsx'; +import { useToast } from '../components/Toast.tsx'; import { useI18n } from '../i18n.tsx'; import { SettingsForm } from '../components/SettingsForm.tsx'; import { TexturePanel } from '../components/TexturePanel.tsx'; import { QuickNewEntry } from '../components/QuickNewEntry.tsx'; import { EntryHeader } from '../components/EntryHeader.tsx'; import { usePreviews } from '../previews.ts'; +import { findParentTrap } from '../../../shared/model-compat.ts'; +import type { Facts } from '../facts.ts'; import type { SessionHook } from '../session.ts'; +import type { View } from '../navigation.ts'; interface Props { session: SessionHook; + facts: Facts; selectedId: string | null; /** Null clears the selection, letting the view fall back to the first entry. */ onSelect: (id: string | null) => void; + onNavigate: (view: View) => void; } -export function EditorView({ session, selectedId, onSelect }: Props) { +export function EditorView({ session, facts, selectedId, onSelect, onNavigate }: Props) { const { t, locale } = useI18n(); + const toast = useToast(); const { project, state } = session; const [error, setError] = useState(null); const [ignored, setIgnored] = useState([]); const [deleting, setDeleting] = useState(false); + const [adding, setAdding] = useState(false); + const [fixingParent, setFixingParent] = useState(false); const previews = usePreviews(Boolean(project)); // Any change of selection cancels a pending delete, so a confirmation can never end up // aimed at an entry other than the one it was opened for. - useEffect(() => setDeleting(false), [selectedId]); + // + // The error goes with it, for the same reason: it named a failure on the entry being + // left, and reading it above a different one is worse than not seeing it at all. The + // capability list too — it describes what the last patched entry's settings did. + useEffect(() => { + setDeleting(false); + setError(null); + setIgnored([]); + }, [selectedId]); const entry = useMemo( () => project?.entries.find((candidate) => candidate.id === selectedId) ?? null, @@ -48,13 +67,30 @@ export function EditorView({ session, selectedId, onSelect }: Props) { // Only the absence of a project is a dead end. An empty project still renders the // sidebar, because that is where the button to fill it lives. - if (!project) return
{t('project.noProject')}
; + if (!project) { + return ( +
+ onNavigate('project'), + }} + /> +
+ ); + } const connected = state.status === 'connected'; - const patch = async (settings: Record): Promise => { - if (!entry) return; - const result = await window.ella.entries.patchLive(entry.id, settings); + // Takes the id from the form rather than from the current selection: a patch can still + // be in flight when the user moves to another entry, and it must land on the one it was + // made for. + const patch = async (id: string, settings: Record): Promise => { + const result = await window.ella.entries.patchLive(id, settings); if (!result.ok) { setError(result.message); return; @@ -63,46 +99,129 @@ export function EditorView({ session, selectedId, onSelect }: Props) { setIgnored(result.value?.ignored ?? []); }; - const act = async (action: () => Promise<{ ok: boolean; message?: string }>): Promise => { + // Read off the preview, which already carries the parsed model and is refreshed on every + // Blockbench save — so the warning appears and clears as the file changes. + const parentTrap = findParentTrap( + previews.find((candidate) => candidate.id === entry?.id)?.model, + ); + + const fixParent = async (id: string): Promise => { + setFixingParent(true); + const result = await window.ella.entries.removeModelParent(id); + setFixingParent(false); + + // Success announces itself as an undoable change; only the failure needs saying here. + if (!result.ok) toast.error(result.message); + }; + + const act = async ( + action: () => Promise<{ ok: boolean; message?: string }>, + success?: string, + ): Promise => { const result = await action(); - setError(result.ok ? null : (result.message ?? null)); + if (result.ok) { + setError(null); + if (success) toast.ok(success); + } else { + toast.error(result.message ?? t('common.error')); + } }; + /* + * Why each action is unavailable, said on the control itself. + * + * These three buttons are dark most of the time — before a launch, before the mod + * connects, on a version whose adapter cannot place blocks — and each has a different + * cause with a different fix. Without the reason they read as broken. + */ + const blockbenchBlocked = facts.blockbenchFound ? null : t('blockbench.notFoundHelp'); + const gameBlocked = !connected + ? t('entry.needsGame') + : entry && entry.slot === null + ? t('entry.needsSlot') + : null; + const placeBlocked = + gameBlocked ?? + (session.hasCapability('entry.place') + ? null + : t('capability.unavailable', { version: state.game?.minecraftVersion ?? '?' })); + return ( -
+
-

{t('project.entries')}

- + {/* Heading and its one action on the same line, as in the project view. The list + then starts directly underneath instead of behind a pair of buttons the eye + has to sort out from the entries. */} +
+

{t('project.entries')}

+ + {!adding && ( + + )} +
- {project.entries.length === 0 && ( -
{t('project.noEntries')}
+ {adding && ( + { + setAdding(false); + onSelect(id); + }} + onCancel={() => setAdding(false)} + onError={setError} + /> )} -
- {project.entries.map((candidate) => ( -
onSelect(candidate.id)} - > - {t(`entry.kind.${candidate.kind}`)} - - {candidate.displayName[locale] ?? candidate.displayName.en} - -
- ))} -
+ {project.entries.length === 0 ? ( +
+ {t('project.noEntriesHelp')} +
+ ) : ( +
+ {project.entries.map((candidate) => ( +
onSelect(candidate.id)} + > + + + {candidate.displayName[locale] ?? candidate.displayName.en} + +
+ ))} +
+ )}
{entry && (
-

{entry.displayName[locale] ?? entry.displayName.en}

-

- {entry.id} ·{' '} - {entry.slot === null ? t('entry.unbound') : `${t('entry.slot')} ${entry.slot}`} -

+
+

{entry.displayName[locale] ?? entry.displayName.en}

+

+ {project.namespace}:{entry.id} ·{' '} + {entry.slot === null ? t('entry.unbound') : `${t('entry.slot')} ${entry.slot}`} +

+
- {error &&
{error}
} + setError(null)} /> + + {/* Blockbench missing blocks the only action on this page that matters, so it + gets a banner with the fix attached rather than a tooltip on a dark button. */} + {blockbenchBlocked && ( +
+ +
+ {t('blockbench.notFound')} —{' '} + +
+
+ )} + {/* A parent silently overrides the model's own geometry on 1.8.x, so the author + sees a plain cube and reasonably concludes Ella lost their work. Shown + whatever version is connected: the file is wrong for 1.8 either way, and + finding out at launch is the failure worth avoiding. */} + {parentTrap && ( +
+ +
+ {t('model.parentTrap', { + parent: parentTrap.parent, + count: parentTrap.elementCount, + })}{' '} + +
+
+ )} + {ignored.length > 0 && (
- {t('capability.unavailable', { - version: state.game?.minecraftVersion ?? '?', - })} - {`: ${ignored.join(', ')}`} + +
+ {t('capability.unavailable', { + version: state.game?.minecraftVersion ?? '?', + })} + {`: ${ignored.join(', ')}`} +
)} {/* Actions are grouped by where they act: the first three reach outside Ella (Blockbench, the running game), the last changes the project itself. */} -
+
-
@@ -155,6 +316,7 @@ export function EditorView({ session, selectedId, onSelect }: Props) { onDone={() => { setDeleting(false); setError(null); + // No toast here: the deletion announces itself, with the way back attached. // The selection now points at something gone; hand it back so the // auto-select effect picks the next entry. onSelect(null); @@ -167,11 +329,12 @@ export function EditorView({ session, selectedId, onSelect }: Props) { void patch(next)} + onChange={patch} />
)} @@ -224,7 +387,8 @@ function DeleteEntry({ entryId, onCancel, onDone, onError }: DeleteEntryProps) { {deleteFiles && (
- {t('entry.deleteFilesWarning')} + +
{t('entry.deleteFilesWarning')}
)} @@ -239,4 +403,3 @@ function DeleteEntry({ entryId, onCancel, onDone, onError }: DeleteEntryProps) {
); } - diff --git a/launcher/src/renderer/src/views/ExportView.tsx b/launcher/src/renderer/src/views/ExportView.tsx index 8aea563..2736217 100644 --- a/launcher/src/renderer/src/views/ExportView.tsx +++ b/launcher/src/renderer/src/views/ExportView.tsx @@ -1,30 +1,38 @@ import { useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from '../components/Icon.tsx'; +import { EmptyState } from '../components/EmptyState.tsx'; +import { useToast } from '../components/Toast.tsx'; import type { SessionHook } from '../session.ts'; import type { ExportIssueDto, ExportResultDto } from '../../../shared/ipc.ts'; export function ExportView({ session }: { session: SessionHook }) { const { t } = useI18n(); + const toast = useToast(); const { project } = session; const [issues, setIssues] = useState(null); const [result, setResult] = useState(null); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - if (!project) return
{t('project.noEntries')}
; + if (!project) { + return ( +
+ +
+ ); + } const blocks = project.entries.filter((entry) => entry.kind === 'block'); const items = project.entries.filter((entry) => entry.kind === 'item'); const run = async (): Promise => { setBusy(true); - setError(null); setResult(null); // Validate first: an export that loads but renders wrongly is worse than a refusal. const validation = await window.ella.exporter.validate(); if (!validation.ok) { - setError(validation.message); + toast.error(validation.message); setBusy(false); return; } @@ -32,6 +40,7 @@ export function ExportView({ session }: { session: SessionHook }) { if (validation.value.some((issue) => issue.severity === 'error')) { setBusy(false); + toast.error(t('export.blocked')); return; } @@ -43,49 +52,81 @@ export function ExportView({ session }: { session: SessionHook }) { } const exported = await window.ella.exporter.run(destination); - if (exported.ok) setResult(exported.value); - else setError(exported.message); setBusy(false); + + if (exported.ok) { + setResult(exported.value); + toast.ok(t('export.doneShort')); + } else { + toast.error(exported.message); + } }; return ( -
-

{t('export.title')}

+
+
+

{t('export.title')}

+

{t('export.subtitle')}

+
- {error &&
{error}
} {result && ( -
- {t('export.done', { path: result.path })} · {result.fileCount} ·{' '} - {(result.bytes / 1024).toFixed(1)} KiB +
+ +
+ {t('export.done', { path: result.path })} · {result.fileCount}{' '} + {t('export.files')} · {(result.bytes / 1024).toFixed(1)} KiB +
)} {issues?.map((issue, index) => ( -
- {issue.entryId} — {t(issue.messageKey)} +
+ +
+ {issue.entryId} — {t(issue.messageKey)} +
))}
-
{t('export.resourcePack')}
-
{t('export.resourcePackHelp')}
-
+
+
+
{t('export.resourcePack')}
+
{t('export.resourcePackHelp')}
+
+ {blocks.length} {t('entry.kind.block')} {items.length} {t('entry.kind.item')} - -
+ {/* Announced rather than hidden: knowing a mod export is planned is the difference + between waiting for it and building the registration by hand. */}
-
{t('export.mod')}
-
{t('export.modHelp')}
+
+
+
{t('export.mod')}
+
{t('export.modHelp')}
+
+ + {t('export.planned')} +
); diff --git a/launcher/src/renderer/src/views/HomeView.tsx b/launcher/src/renderer/src/views/HomeView.tsx new file mode 100644 index 0000000..66cea9a --- /dev/null +++ b/launcher/src/renderer/src/views/HomeView.tsx @@ -0,0 +1,363 @@ +/** + * The view Ella opens on. + * + * It answers one question — what should I do next — and answers it differently depending + * on how far along you are. Before the setup is complete it is a guide: five steps read + * off live state, with the next one accented and its button wired to the thing it names. + * After that the checklist folds away and the same space becomes a dashboard of what is + * running, because a finished checklist is not worth a screen. + */ + +import { useEffect, useState } from 'react'; +import { useI18n } from '../i18n.tsx'; +import { Icon, type IconName } from '../components/Icon.tsx'; +import { useToast } from '../components/Toast.tsx'; +import { ModelPreview } from '../components/ModelPreview.tsx'; +import { usePreviews } from '../previews.ts'; +import type { ProjectSummaryDto } from '../../../shared/ipc.ts'; +import { + workflowSteps, + isSetupComplete, + completedCount, + type WorkflowStep, + type WorkflowStepId, +} from '../../../shared/workflow.ts'; +import type { Facts } from '../facts.ts'; +import type { SessionHook } from '../session.ts'; +import type { View } from '../navigation.ts'; + +interface Props { + session: SessionHook; + facts: Facts; + onNavigate: (view: View) => void; + onOpenEntry: (id: string) => void; +} + +/** Each step's button either moves you to where it happens, or does the thing outright. */ +const STEP_ICON: Record = { + version: 'download', + project: 'folder', + entry: 'plus', + blockbench: 'brush', + launch: 'play', +}; + +export function HomeView({ session, facts, onNavigate, onOpenEntry }: Props) { + const { t, locale } = useI18n(); + const toast = useToast(); + const [showGuide, setShowGuide] = useState(false); + const [launching, setLaunching] = useState(false); + + const steps = workflowSteps(facts); + const complete = isSetupComplete(steps); + const { project, state } = session; + const previews = usePreviews(Boolean(project)); + const [projects, setProjects] = useState([]); + + // Only needed while nothing is open — that is the one moment the answer to "what now" + // is most likely to be "carry on with the one I had yesterday". + useEffect(() => { + if (project) return; + void window.ella.projects.list().then(setProjects); + }, [project]); + + const open = async (root: string): Promise => { + const result = await window.ella.projects.open(root); + if (!result.ok) toast.error(result.message); + }; + + const launch = async (): Promise => { + if (!facts.preferredVersion) return; + setLaunching(true); + const result = await session.requestLaunch(facts.preferredVersion); + setLaunching(false); + if (!result.ok) toast.error(result.message); + }; + + const act = (step: WorkflowStepId): void => { + if (step === 'launch') void launch(); + else if (step === 'version') onNavigate('versions'); + else if (step === 'blockbench') onNavigate('settings'); + else onNavigate('project'); + }; + + const running = state.status === 'running' || state.status === 'starting'; + + return ( +
+
+

{project ? project.name : t('home.welcome')}

+

+ {project ? t('home.projectSubtitle', { namespace: project.namespace }) : t('home.welcomeText')} +

+ +
+ + 0 ? 'normal' : 'dim'} + /> + 0 ? 'normal' : 'dim'} + /> + +
+
+ + {/* One warning that outranks the checklist: versions are installed but none of them + can live-edit, so following every step still ends in a game that never updates. */} + {facts.installedVersions > 0 && facts.liveEditingVersions === 0 && ( +
+ +
+ {t('home.noLiveVersion')}{' '} + +
+
+ )} + + {/* + With nothing open, the fastest route back to work is almost never "create a + project" — it is the one from yesterday. The list goes above the checklist for + that reason, and stays a plain overview: a name, its namespace and how much is in + it, which is all that is knowable without opening the project. + */} + {!project && projects.length > 0 && ( + <> +

{t('home.resume')}

+

{t('home.resumeHelp')}

+ +
+ {projects.map((summary) => ( + + ))} + + +
+ + )} + + {complete ? ( + <> +
+

{t('home.quickActions')}

+ + +
+ +
+ onNavigate('editor')} + /> + onNavigate('project')} + /> + onNavigate('export')} + /> + onNavigate('logs')} + /> +
+ + {showGuide && ( +
+ +
+ )} + + {project && project.entries.length > 0 && ( + <> +

{t('home.recent')}

+
+ {project.entries.slice(0, 6).map((entry) => ( + + ))} +
+ + )} + + ) : ( + <> +
+

{t('guide.title')}

+ + {t('guide.progress', { done: completedCount(steps), total: steps.length })} + +
+

+ {t('guide.subtitle')} +

+ + + )} +
+ ); +} + +// --------------------------------------------------------------------------- + +interface GuideProps { + steps: WorkflowStep[]; + launching: boolean; + onAct: (step: WorkflowStepId) => void; +} + +function Guide({ steps, launching, onAct }: GuideProps) { + const { t } = useI18n(); + + return ( +
+ {steps.map((step, index) => ( +
+
+ {step.done ? : index + 1} +
+ +
+
{t(`guide.${step.id}.title`)}
+ {/* Why, not how. The button already covers how, and a step whose point is + unclear gets skipped or undone later. */} + {!step.done &&
{t(`guide.${step.id}.why`)}
} +
+ + {!step.done && ( + + )} +
+ ))} +
+ ); +} + +interface StatProps { + label: string; + value: string; + tone: 'normal' | 'ok' | 'dim'; + dot?: 'connected' | 'running' | null; +} + +function Stat({ label, value, tone, dot }: StatProps) { + return ( +
+
+ {dot !== undefined && dot !== null && } + {value} +
+
{label}
+
+ ); +} + +interface ActionCardProps { + icon: IconName; + title: string; + text: string; + onClick: () => void; +} + +function ActionCard({ icon, title, text, onClick }: ActionCardProps) { + return ( + + ); +} diff --git a/launcher/src/renderer/src/views/LogsView.tsx b/launcher/src/renderer/src/views/LogsView.tsx index d50f7cc..4a3c33b 100644 --- a/launcher/src/renderer/src/views/LogsView.tsx +++ b/launcher/src/renderer/src/views/LogsView.tsx @@ -46,12 +46,22 @@ export function LogsView({ session }: { session: SessionHook }) { return tally; }, [session.lines]); + const connected = session.state.status === 'connected'; + const running = session.state.status !== 'stopped'; + return ( -
-

{t('nav.logs')}

-

- {session.state.status === 'stopped' ? t('game.disconnected') : t('game.connected')} -

+
+
+

{t('nav.logs')}

+

+ + {connected + ? t('game.connected') + : running + ? t('versions.launching') + : t('game.disconnected')} +

+
([]); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); const [deletingProject, setDeletingProject] = useState(false); const [editing, setEditing] = useState(false); + const [addingEntry, setAddingEntry] = useState(false); // Cards by default: seeing the models is the point of this list. const [view, setView] = useState<'cards' | 'rows'>('cards'); const previews = usePreviews(Boolean(project)); @@ -40,140 +50,166 @@ export function ProjectView({ session, onOpenEntry }: Props) { unwrapOr(await window.ella.projects.close(), (message) => setError(message)); }; - return ( -
-

{t('project.title')}

- {error &&
{error}
} - - {!project || creating ? ( + if (!project || creating) { + return ( +
+
+

{t('project.new')}

+

{t('project.newSubtitle')}

+
+ setError(null)} /> setCreating(false) : undefined} - onCreated={() => { + onCreated={(name) => { setCreating(false); + toast.ok(t('project.createdDone', { name })); refresh(); }} onOpen={open} /> - ) : ( - <> -
-
-
-
{project.name}
-
- {project.namespace} · {project.entries.length} {t('project.entries').toLowerCase()} -
- {session.state.projectRoot && ( -
- {session.state.projectRoot} -
- )} -
- - - - - -
+
+ ); + } + + return ( +
+
+
+
+

{project.name}

+

+ {project.namespace} · {project.entries.length}{' '} + {t('project.entries').toLowerCase()} ·{' '} + {project.targetVersion ?? t('project.targetVersionNone')} +

+ + + + + +
+ {session.state.projectRoot && ( +
{session.state.projectRoot}
+ )} +
- {editing && ( - setEditing(false)} - onError={setError} - /> - )} - - {deletingProject && session.state.projectRoot && ( - setDeletingProject(false)} - onDone={() => { - setDeletingProject(false); - refresh(); - }} - onError={setError} - /> - )} + setError(null)} /> - + {editing && ( + { + setEditing(false); + toast.ok(t('project.savedDone')); + }} + onCancel={() => setEditing(false)} + onError={setError} + /> + )} -
-

{t('project.entries')}

- -
- - -
+ {deletingProject && session.state.projectRoot && ( + setDeletingProject(false)} + onDone={() => { + setDeletingProject(false); + toast.ok(t('project.deletedDone', { name: project.name })); + refresh(); + }} + onError={setError} + /> + )} + +
+

{t('project.entries')}

+ + {project.entries.length > 0 && ( +
+ +
+ )} + +
- {project.entries.length === 0 ? ( -
{t('project.noEntries')}
- ) : view === 'rows' ? ( -
- {project.entries.map((entry) => ( -
onOpenEntry(entry.id)} - > - {t(`entry.kind.${entry.kind}`)} - - {entry.displayName[locale] ?? entry.displayName.en} - - {entry.id} - - - {entry.slot === null - ? t('entry.unbound') - : `${t('entry.slot')} ${entry.slot}`} - -
- ))} -
- ) : ( -
- {project.entries.map((entry) => ( - - ))} + {(addingEntry || project.entries.length === 0) && ( + { + setAddingEntry(false); + toast.ok(t('entry.createdDone', { name })); + onOpenEntry(id); + }} + onCancel={project.entries.length > 0 ? () => setAddingEntry(false) : undefined} + onError={setError} + /> + )} + + {project.entries.length === 0 ? ( + !addingEntry && ( + + ) + ) : view === 'rows' ? ( +
+ {project.entries.map((entry) => ( +
onOpenEntry(entry.id)}> + + + {entry.displayName[locale] ?? entry.displayName.en} + + + {project.namespace}:{entry.id} + + + + {entry.slot === null ? t('entry.unbound') : `${t('entry.slot')} ${entry.slot}`} +
- )} - + ))} +
+ ) : ( +
+ {project.entries.map((entry) => ( + + ))} +
)}
); @@ -184,32 +220,44 @@ export function ProjectView({ session, onOpenEntry }: Props) { interface EditProjectProps { project: EllaProject; onDone: () => void; + onCancel: () => void; onError: (message: string) => void; } /** - * Editing the open project's name and namespace. + * Editing the open project's name, namespace and target version. * * The namespace warning is not decoration: it names the asset directory and appears in * every texture reference inside the models, so changing it rewrites files. Ella handles * that, but anything referencing the old namespace from outside the project — a hand-written * model, an already-exported pack — will not follow. + * + * Changing the target version here deliberately does *not* migrate the models: this is the + * place to correct a binding that was wrong, and the launch dialog is the place to move a + * project across versions, because that is where the consequences can be listed against + * the files as they actually are. */ -function EditProject({ project, onDone, onError }: EditProjectProps) { +function EditProject({ project, onDone, onCancel, onError }: EditProjectProps) { const { t } = useI18n(); const [name, setName] = useState(project.name); const [namespace, setNamespace] = useState(project.namespace); + const [targetVersion, setTargetVersion] = useState(project.targetVersion ?? ''); const [busy, setBusy] = useState(false); + const versions = useInstalledVersions(); const namespaceChanged = namespace.trim() !== project.namespace; const valid = name.trim().length > 0 && isValidIdentifier(namespace.trim()); - const changed = name.trim() !== project.name || namespaceChanged; + const changed = + name.trim() !== project.name || + namespaceChanged || + targetVersion !== (project.targetVersion ?? ''); const save = async (): Promise => { setBusy(true); const result = await window.ella.projects.updateInfo({ name: name.trim(), namespace: namespace.trim(), + targetVersion: targetVersion === '' ? null : targetVersion, }); setBusy(false); @@ -219,33 +267,55 @@ function EditProject({ project, onDone, onError }: EditProjectProps) { return (
-

{t('project.edit')}

- -
- - setName(event.target.value)} /> +
+ {t('project.edit')}
-
- - setNamespace(event.target.value)} /> -
{t('project.namespaceHelp')}
+
+
+ + setName(event.target.value)} /> +
+ +
+ + setNamespace(event.target.value)} /> +
{t('project.namespaceHelp')}
+
+ +
+ + +
{t('project.targetVersionHelp')}
+
{namespaceChanged && (
- {t('project.namespaceChangeWarning', { - from: project.namespace, - to: namespace.trim(), - })} + +
+ {t('project.namespaceChangeWarning', { + from: project.namespace, + to: namespace.trim(), + })} +
)}
- -
@@ -293,11 +363,14 @@ function DeleteProject({ root, name, onCancel, onDone, onError }: DeleteProjectP
{t('project.deleteTitle', { name })}
-
- {t('project.deleteWarning', { - entries: footprint?.entryCount ?? 0, - files: footprint?.authoredFiles ?? 0, - })} +
+ +
+ {t('project.deleteWarning', { + entries: footprint?.entryCount ?? 0, + files: footprint?.authoredFiles ?? 0, + })} +
@@ -310,6 +383,7 @@ function DeleteProject({ root, name, onCancel, onDone, onError }: DeleteProjectP className="danger" onClick={() => void confirm()} disabled={busy || typed !== name} + title={typed !== name ? t('project.deleteConfirmLabel', { name }) : undefined} > {t('project.delete')} @@ -321,10 +395,46 @@ function DeleteProject({ root, name, onCancel, onDone, onError }: DeleteProjectP ); } +/** + * Picks the version a project is authored against. + * + * "None" is a real answer rather than a placeholder: an unbound project takes the version + * of its next launch, which is how every project made before this field existed acquires + * one without being asked. + */ +function VersionSelect({ + value, + versions, + onChange, +}: { + value: string; + versions: VersionSummaryDto[]; + onChange: (value: string) => void; +}) { + const { t } = useI18n(); + const known = versions.some((version) => version.id === value); + + return ( + + ); +} + interface CreateProps { existing: ProjectSummaryDto[]; onCancel?: () => void; - onCreated: () => void; + onCreated: (name: string) => void; onOpen: (root: string) => void; } @@ -333,63 +443,107 @@ function CreateProject({ existing, onCancel, onCreated, onOpen }: CreateProps) { const [name, setName] = useState(''); const [namespace, setNamespace] = useState(''); const [touched, setTouched] = useState(false); + const [targetVersion, setTargetVersion] = useState(''); const [error, setError] = useState(null); + const versions = useInstalledVersions(); // The namespace tracks the name until the user edits it themselves. const effectiveNamespace = touched ? namespace : slugify(name); const valid = name.trim().length > 0 && isValidIdentifier(effectiveNamespace); const create = async (): Promise => { - const result = await window.ella.projects.create(name.trim(), effectiveNamespace, []); - if (unwrapOr(result, (message) => setError(message))) onCreated(); + const result = await window.ella.projects.create( + name.trim(), + effectiveNamespace, + targetVersion === '' ? null : targetVersion, + ); + if (unwrapOr(result, (message) => setError(message))) onCreated(name.trim()); }; return ( <> -
-

{t('project.new')}

- {error &&
{error}
} - -
- - setName(event.target.value)} /> -
- -
- - { - setTouched(true); - setNamespace(event.target.value); - }} - /> -
{t('project.namespaceHelp')}
-
- -
- - {onCancel && } -
-
- + {/* Opening beats creating when something is already there, so the existing list + comes first — a second project made by accident is a split workspace. */} {existing.length > 0 && ( <> -

{t('project.open')}

-
+

{t('project.open')}

+
{existing.map((summary) => ( -
onOpen(summary.root)}> +
onOpen(summary.root)} + > + {summary.name} {summary.namespace} - {summary.entryCount} + {/* The version comes before the entry count: it is what decides whether + opening this project also changes what the launcher will start. */} + {summary.targetVersion && ( + {summary.targetVersion} + )} + + {summary.entryCount} {t('project.entries').toLowerCase()} + +
))}
+

{t('project.new')}

)} + +
+ setError(null)} /> + +
+
+ + setName(event.target.value)} + /> +
+ +
+ + { + setTouched(true); + setNamespace(event.target.value); + }} + /> +
{t('project.namespaceHelp')}
+
+ +
+ + +
{t('project.targetVersionHelp')}
+
+
+ +
+ + {onCancel && } +
+
); } @@ -397,18 +551,26 @@ function CreateProject({ existing, onCancel, onCreated, onOpen }: CreateProps) { // --------------------------------------------------------------------------- interface NewEntryProps { - session: SessionHook; - onCreated: (id: string) => void; + onCreated: (id: string, name: string) => void; + onCancel?: () => void; onError: (message: string) => void; } -function NewEntry({ session, onCreated, onError }: NewEntryProps) { +/** + * Creating a block or an item. + * + * The kind is picked from two labelled buttons rather than a dropdown: it is the one + * choice here that cannot be changed afterwards, and a closed `select` showing "Block" + * does not read as a decision at all. + */ +function NewEntry({ onCreated, onCancel, onError }: NewEntryProps) { const { t } = useI18n(); const [kind, setKind] = useState('block'); const [nameEn, setNameEn] = useState(''); const [nameFr, setNameFr] = useState(''); const [id, setId] = useState(''); const [touched, setTouched] = useState(false); + const [busy, setBusy] = useState(false); const effectiveId = touched ? id : slugify(nameEn); const valid = nameEn.trim().length > 0 && isValidIdentifier(effectiveId); @@ -418,47 +580,84 @@ function NewEntry({ session, onCreated, onError }: NewEntryProps) { ? { en: nameEn.trim(), fr: nameFr.trim() } : { en: nameEn.trim() }; + setBusy(true); const result = await window.ella.entries.create({ id: effectiveId, kind, displayName }); - const entry = result.ok ? result.value : null; - if (!entry) { - onError(result.ok ? '' : result.message); + setBusy(false); + + if (!result.ok) { + onError(result.message); return; } + const created = result.value; setNameEn(''); setNameFr(''); setId(''); setTouched(false); - onCreated(entry.id); + onCreated(created.id, nameEn.trim()); }; - return ( -
-

{t('entry.new')}

+ const submitOnEnter = (event: KeyboardEvent): void => { + if (event.key === 'Enter' && valid && !busy) void create(); + }; -
-
- - + return ( +
+
+ +
+ {(['block', 'item'] as EntryKind[]).map((candidate) => ( + + ))}
+
+
- setNameEn(event.target.value)} /> + setNameEn(event.target.value)} + />
- setNameFr(event.target.value)} /> + setNameFr(event.target.value)} + />
{ setTouched(true); setId(event.target.value); @@ -468,14 +667,18 @@ function NewEntry({ session, onCreated, onError }: NewEntryProps) {
- - {session.state.status === 'connected' && ( - - {t('game.connected')} - - )} +
+ + {onCancel && } +
); } diff --git a/launcher/src/renderer/src/views/SettingsView.tsx b/launcher/src/renderer/src/views/SettingsView.tsx index ccebee1..7745845 100644 --- a/launcher/src/renderer/src/views/SettingsView.tsx +++ b/launcher/src/renderer/src/views/SettingsView.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; import { useI18n } from '../i18n.tsx'; +import { Icon } from '../components/Icon.tsx'; +import { useToast } from '../components/Toast.tsx'; import { LOCALES, LOCALE_NAMES, type Locale } from '../../../shared/i18n.ts'; import type { AppConfigDto, JavaRuntimeDto } from '../../../shared/ipc.ts'; @@ -9,10 +11,10 @@ import type { AppConfigDto, JavaRuntimeDto } from '../../../shared/ipc.ts'; */ function BlockbenchPlugin() { const { t } = useI18n(); + const toast = useToast(); const [status, setStatus] = useState< { installed: boolean; outdated: boolean; installedPath: string } | null >(null); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const refresh = (): void => { @@ -24,45 +26,52 @@ function BlockbenchPlugin() { const install = async (): Promise => { setBusy(true); const result = await window.ella.blockbench.installPlugin(); - if (!result.ok) setError(result.message); - else setError(null); setBusy(false); + if (result.ok) toast.ok(t('plugin.installedDone')); + else toast.error(result.message); refresh(); }; return ( - <> -

{t('plugin.title')}

- {error &&
{error}
} -
-
-
-
{t('plugin.liveSync')}
-
{t('plugin.liveSyncHelp')}
-
- - {status?.installed && !status.outdated && ( - {t('plugin.installed')} - )} - {status?.outdated && {t('plugin.outdated')}} - +
+
+
+
{t('plugin.liveSync')}
+
{t('plugin.liveSyncHelp')}
- {status?.installed &&
{status.installedPath}
} + + {status?.installed && !status.outdated && ( + {t('plugin.installed')} + )} + {status?.outdated && {t('plugin.outdated')}} +
- + {status?.installed &&
{status.installedPath}
} +
); } -export function SettingsView() { +interface Props { + /** A Blockbench path change moves a setup step from undone to done. */ + onChanged: () => void; +} + +export function SettingsView({ onChanged }: Props) { const { t, locale, setLocale } = useI18n(); const [config, setConfig] = useState(null); const [runtimes, setRuntimes] = useState([]); + const [detected, setDetected] = useState(null); + + const refreshDetected = (): void => { + void window.ella.blockbench.resolve().then(setDetected); + }; useEffect(() => { void window.ella.config.get().then(setConfig); void window.ella.java.list().then(setRuntimes); + refreshDetected(); }, []); if (!config) return null; @@ -72,51 +81,88 @@ export function SettingsView() { void window.ella.config.set(patch); }; + const updateBlockbench = (path: string | null): void => { + update({ blockbenchPath: path }); + // Resolution also falls back to the well-known install locations, so the answer to + // "will opening a model work" is the main process's, not this input's. + setTimeout(() => { + refreshDetected(); + onChanged(); + }, 0); + }; + const browseBlockbench = async (): Promise => { const chosen = await window.ella.dialog.openFile([ { name: 'Blockbench', extensions: ['exe', 'app', ''] }, ]); - if (chosen) update({ blockbenchPath: chosen }); + if (chosen) updateBlockbench(chosen); }; return ( -
-

{t('settings.title')}

- -
- - +
+
+

{t('settings.title')}

+

{t('settings.subtitle')}

-
- - update({ username: event.target.value })} - /> +
+
+ + +
+ +
+ + update({ username: event.target.value })} + /> +
{t('settings.usernameHelp')}
+
-
+

{t('settings.blockbench')}

+ +
update({ blockbenchPath: event.target.value || null })} + placeholder={t('settings.blockbenchPlaceholder')} + onChange={(event) => updateBlockbench(event.target.value || null)} /> - +
+ {/* Whether Ella can find Blockbench is not the same question as whether this box + is filled in, and only the first one decides if opening a model works. */} + {detected ? ( +
+ {config.blockbenchPath + ? t('settings.blockbenchOk') + : t('settings.blockbenchDetected', { path: detected })} +
+ ) : ( +
+ {t('settings.blockbenchMissing')} +
+ )}
+ + +

{t('settings.slotPool')}

- +
- +
-
+
{t('settings.slotPoolHelp')}
- -

Java

-
- {runtimes.map((runtime) => ( -
- Java {runtime.major} - {runtime.version} - - {runtime.path} -
- ))} -
+

{t('settings.javaHelp')}

+ {runtimes.length === 0 ? ( +
+ +
{t('settings.javaNone')}
+
+ ) : ( +
+ {runtimes.map((runtime) => ( +
+ Java {runtime.major} + {runtime.version} + + {runtime.path} +
+ ))} +
+ )}
); } diff --git a/launcher/src/renderer/src/views/VersionsView.tsx b/launcher/src/renderer/src/views/VersionsView.tsx index 79b6018..47f4b1a 100644 --- a/launcher/src/renderer/src/views/VersionsView.tsx +++ b/launcher/src/renderer/src/views/VersionsView.tsx @@ -1,11 +1,22 @@ import { useEffect, useState } from 'react'; import { useI18n } from '../i18n.tsx'; import { unwrapOr } from '../result.ts'; +import { Icon } from '../components/Icon.tsx'; +import { EmptyState } from '../components/EmptyState.tsx'; +import { ErrorBanner } from '../components/ErrorBanner.tsx'; +import { useToast } from '../components/Toast.tsx'; import type { SessionHook } from '../session.ts'; import type { VersionSummaryDto, InstallFootprintDto } from '../../../shared/ipc.ts'; -export function VersionsView({ session }: { session: SessionHook }) { +interface Props { + session: SessionHook; + /** Installs and uninstalls change what the setup guide and the sidebar count. */ + onChanged: () => void; +} + +export function VersionsView({ session, onChanged }: Props) { const { t } = useI18n(); + const toast = useToast(); const [versions, setVersions] = useState([]); const [showSnapshots, setShowSnapshots] = useState(false); const [busy, setBusy] = useState(null); @@ -33,6 +44,7 @@ export function VersionsView({ session }: { session: SessionHook }) { const refreshAll = async (): Promise => { await Promise.all([refreshInstalled(), refresh(showSnapshots)]); + onChanged(); }; useEffect(() => { @@ -49,51 +61,62 @@ export function VersionsView({ session }: { session: SessionHook }) { void refresh(next); }; - const install = async (id: string): Promise => { + const install = async (id: string, repair = false): Promise => { setBusy(id); const result = await window.ella.versions.install(id); - unwrapOr(result, (message) => setError(message)); setBusy(null); + + if (result.ok) { + toast.ok(t(repair ? 'versions.repairDone' : 'versions.installDone', { version: id })); + } else { + toast.error(result.message); + } + void refreshAll(); }; const launch = async (id: string): Promise => { setBusy(id); - const result = await window.ella.game.launch(id); - unwrapOr(result, (message) => setError(message)); + // Through the session, so launching from the list is guarded the same way as launching + // from the sidebar — this is the button most likely to be a deliberate version change. + const result = await session.requestLaunch(id); + unwrapOr(result, (message) => toast.error(message)); setBusy(null); }; const gameRunning = session.state.status !== 'stopped'; const installedIds = new Set(installed.map((version) => version.id)); + const available = versions.filter((version) => !installedIds.has(version.id)); + + /* + * Versions with a built adapter get their own section above the full list. + * + * "Which Minecraft version should I pick" is the first decision Ella asks for and the + * one it is worst at leaving to the user: every version in the manifest launches, but + * only a handful can live-edit, and picking wrong is only discovered after a download + * and a launch that quietly never syncs. + */ + const recommended = available.filter((version) => version.adapterStatus === 'built'); + const rest = available.filter((version) => version.adapterStatus !== 'built'); + + const rowProps = (version: VersionSummaryDto) => ({ + version, + busy: busy === version.id, + gameRunning, + onInstall: () => void install(version.id, version.installed), + onLaunch: () => void launch(version.id), + onUninstall: () => setConfirming(version.id), + }); return ( -
-

{t('versions.title')}

-

{t('app.tagline')}

- - {error &&
{error}
} - -
- - - - {gameRunning && ( - - )} +
+
+

{t('versions.title')}

+

{t('versions.subtitle')}

+ setError(null)} /> + {/* Installed versions first and unfiltered: they are what you actually launch, and they stay visible even when the snapshot filter hides them from the list below or the manifest cannot be reached. */} @@ -101,30 +124,26 @@ export function VersionsView({ session }: { session: SessionHook }) { {t('versions.installed')} ({installed.length}) {installed.length === 0 ? ( -
- {t('versions.noneInstalled')} -
+ ) : ( -
+
{installed.map((version) => (
- void install(version.id)} - onLaunch={() => void launch(version.id)} - onUninstall={() => setConfirming(version.id)} - /> + {confirming === version.id && ( { setConfirming(null); + toast.ok(t('versions.uninstallDone', { version: version.id })); void refreshAll(); }} onCancel={() => setConfirming(null)} - onError={setError} + onError={(message) => toast.error(message)} /> )}
@@ -132,25 +151,48 @@ export function VersionsView({ session }: { session: SessionHook }) {
)} -

{t('versions.available')}

+ {recommended.length > 0 && ( + <> +

{t('versions.recommended')}

+

{t('versions.recommendedHelp')}

+
+ {recommended.map((version) => ( + + ))} +
+ + )} + +
+

{t('versions.available')}

+ + + +
+ {loading && versions.length === 0 &&
} {/* Anything installed is shown above, so this list is the not-yet-installed remainder — no uninstall affordance can apply here. */}
- {versions - .filter((version) => !installedIds.has(version.id)) - .map((version) => ( - void install(version.id)} - onLaunch={() => void launch(version.id)} - onUninstall={() => setConfirming(version.id)} - /> - ))} + {rest.map((version) => ( + + ))}
); @@ -159,18 +201,33 @@ export function VersionsView({ session }: { session: SessionHook }) { interface RowProps { version: VersionSummaryDto; busy: boolean; - disabled: boolean; + gameRunning: boolean; onInstall: () => void; onLaunch: () => void; onUninstall: () => void; } -function VersionRow({ version, busy, disabled, onInstall, onLaunch, onUninstall }: RowProps) { +function VersionRow({ version, busy, gameRunning, onInstall, onLaunch, onUninstall }: RowProps) { const { t } = useI18n(); + /* + * Why a button cannot be pressed, in the order the user would hit them. + * + * A disabled button with no tooltip is a dead end — people conclude the app is broken + * rather than that something is missing, and the fix (install a JDK, stop the game) is + * never something they would guess. + */ + const launchBlocked = !version.javaAvailable + ? t('versions.javaMissing', { java: version.requiredJava }) + : gameRunning + ? t('versions.alreadyRunning') + : null; + + const installBlocked = !version.supported ? t('versions.unsupportedReason') : null; + return (
- + {version.id} @@ -183,41 +240,70 @@ function VersionRow({ version, busy, disabled, onInstall, onLaunch, onUninstall {t('versions.adapterNotBuilt')} ) : ( - + {t('versions.vanillaOnly')} )} {version.javaAvailable ? ( - {t('versions.javaRequired', { java: version.requiredJava })} + + {t('versions.javaRequired', { java: version.requiredJava })} + ) : ( {t('versions.javaMissing', { java: version.requiredJava })} )} - {version.installed && {t('versions.installed')}} - {version.releaseTime.slice(0, 10)} {!version.installed && ( - )} + {/* Launch only appears once there is something to launch. A disabled Launch on every + uninstalled row is a column of dead buttons, and Install is already the one + action that applies. */} {version.installed && ( - + <> + {/* Re-runs the whole install over an existing one. Needed because a version can + be installed and still incomplete — a Forge step that failed leaves files + missing, and without this the only way to fetch them was to uninstall and + start over, which also throws away the worlds. */} + + + + )} -
); } @@ -262,7 +348,7 @@ function UninstallConfirm({ versionId, onDone, onCancel, onError }: ConfirmProps }; return ( -
+
{t('versions.uninstallTitle', { version: versionId })}
@@ -285,7 +371,8 @@ function UninstallConfirm({ versionId, onDone, onCancel, onError }: ConfirmProps {removeInstance && (
- {t('versions.uninstallWorldsWarning')} + +
{t('versions.uninstallWorldsWarning')}
)} diff --git a/launcher/src/shared/app.ts b/launcher/src/shared/app.ts new file mode 100644 index 0000000..e964f67 --- /dev/null +++ b/launcher/src/shared/app.ts @@ -0,0 +1,14 @@ +/** + * The launcher's own identity. + * + * Shared rather than main-only because it is needed on both sides: Minecraft is told which + * launcher started it, a crash report has to name the build it came from, and the sidebar + * shows it so a user filing that report does not have to go looking. + * + * Written out rather than read from package.json, because the renderer has no filesystem + * and bundling the manifest to recover one string is not a trade worth making. A test keeps + * the two in step. + */ + +export const APP_NAME = 'Ella'; +export const APP_VERSION = '0.2.0'; diff --git a/launcher/src/shared/ipc.ts b/launcher/src/shared/ipc.ts index fdd02f2..ae6039f 100644 --- a/launcher/src/shared/ipc.ts +++ b/launcher/src/shared/ipc.ts @@ -55,6 +55,36 @@ export interface ProjectSummaryDto { namespace: string; root: string; entryCount: number; + /** The Minecraft version it is authored against, or null when unbound. */ + targetVersion: string | null; +} + +/** One thing in the project's files that a version change would break. */ +export interface CompatFindingDto { + entryId: string; + /** A {@link CompatIssueId}; also the suffix of its `compat.issue.*` message. */ + issue: string; + /** True when Ella can rewrite the file itself. */ + fixable: boolean; + /** Interpolation values for the message. */ + detail: Record; +} + +export interface VersionChangePlanDto { + /** The version the project is bound to, or null when nothing has bound it yet. */ + from: string | null; + to: string; + /** True when this launch is a change worth stopping for. */ + needsConfirmation: boolean; + findings: CompatFindingDto[]; + /** How many findings Ella can rewrite the files for. */ + fixable: number; +} + +export interface VersionChangeResultDto { + project: EllaProject; + /** Entry ids whose model files were rewritten. */ + migrated: string[]; } export interface TextureVariableDto { @@ -153,6 +183,20 @@ export interface ExportResultDto { bytes: number; } +/** + * An action that can still be taken back, pushed as an event the moment it happens. + * + * Sent rather than returned so the offer is independent of whichever call produced it: the + * notification layer subscribes once, and a handler becomes undoable by registering an + * inverse instead of by changing its signature. + */ +export interface UndoOfferDto { + token: string; + /** i18n key describing what happened; the renderer translates it. */ + messageKey: string; + values: Record; +} + export interface SettingsPatchResultDto { applied: string[]; ignored: string[]; @@ -190,7 +234,7 @@ export interface EllaApi { projects: { list(): Promise; - create(name: string, namespace: string, targetVersions: string[]): Promise< + create(name: string, namespace: string, targetVersion: string | null): Promise< Result<{ project: EllaProject; root: string }> >; open(root: string): Promise>; @@ -199,12 +243,24 @@ export interface EllaApi { measure(): Promise>; delete(root: string): Promise>; close(): Promise>; - /** Edits the open project's name, namespace and target versions. */ + /** Edits the open project's name, namespace and target version. */ updateInfo(changes: { name?: string; namespace?: string; - targetVersions?: string[]; + /** Null unbinds it; omitted leaves it alone. */ + targetVersion?: string | null; }): Promise>; + /** + * What launching `versionId` would mean for the open project, without changing + * anything. Reads every entry's model, so it is the answer for the files as they + * actually are rather than as the manifest describes them. + */ + planVersionChange(versionId: string): Promise>; + /** Binds the project to `versionId`, rewriting the models that need it when asked. */ + applyVersionChange( + versionId: string, + migrate: boolean, + ): Promise>; }; entries: { @@ -236,12 +292,22 @@ export interface EllaApi { /** Points the model's `particle` variable at this one, or clears it with null. */ setParticleTexture(id: string, key: string | null): Promise>; revealTexture(id: string): Promise>; + /** + * Strips a `parent` that would override the model's own geometry on 1.8.x. + * Resolves to the removed parent, or null when there was nothing to fix. + */ + removeModelParent(id: string): Promise>; /** Preview data for every entry, batched for the card grid. */ previews(): Promise>; give(id: string): Promise>; place(id: string): Promise>; }; + undo: { + /** Reverses the action a {@link UndoOfferDto} names. One-shot. */ + run(token: string): Promise>; + }; + game: { launch(versionId: string): Promise>; stop(): Promise>; @@ -256,6 +322,13 @@ export interface EllaApi { }; blockbench: { + /** + * The executable Ella would actually launch, or null when it cannot find one. + * + * Not the same as the configured path: Blockbench is usually auto-detected, so an + * empty setting says nothing about whether opening a model will work. + */ + resolve(): Promise; pluginStatus(): Promise<{ installed: boolean; outdated: boolean; installedPath: string }>; installPlugin(): Promise>; }; @@ -284,6 +357,8 @@ export interface EllaApi { crash(handler: (diagnostics: CrashDiagnosticsDto) => void): () => void; /** Fires when watched model or texture files change on disk. */ files(handler: (paths: string[]) => void): () => void; + /** Fires after a change that can still be taken back. */ + undo(handler: (offer: UndoOfferDto) => void): () => void; }; } @@ -305,6 +380,8 @@ export const CHANNELS = { projectsDelete: 'projects:delete', projectsClose: 'projects:close', projectsUpdateInfo: 'projects:updateInfo', + projectsPlanVersionChange: 'projects:planVersionChange', + projectsApplyVersionChange: 'projects:applyVersionChange', entriesCreate: 'entries:create', entriesUpdate: 'entries:update', entriesDelete: 'entries:delete', @@ -317,9 +394,11 @@ export const CHANNELS = { entriesRemoveTexture: 'entries:removeTexture', entriesSetParticle: 'entries:setParticle', entriesRevealTexture: 'entries:revealTexture', + entriesRemoveModelParent: 'entries:removeModelParent', entriesPreviews: 'entries:previews', entriesGive: 'entries:give', entriesPlace: 'entries:place', + undoRun: 'undo:run', gameLaunch: 'game:launch', gameStop: 'game:stop', gameState: 'game:state', @@ -327,6 +406,7 @@ export const CHANNELS = { exportValidate: 'export:validate', exportRun: 'export:run', exportSuggestName: 'export:suggestName', + blockbenchResolve: 'blockbench:resolve', blockbenchPluginStatus: 'blockbench:pluginStatus', blockbenchInstallPlugin: 'blockbench:installPlugin', crashCopy: 'crash:copy', @@ -343,4 +423,5 @@ export const EVENTS = { progress: 'event:progress', crash: 'event:crash', files: 'event:files', + undo: 'event:undo', } as const; diff --git a/launcher/src/shared/model-compat.ts b/launcher/src/shared/model-compat.ts new file mode 100644 index 0000000..0dde560 --- /dev/null +++ b/launcher/src/shared/model-compat.ts @@ -0,0 +1,68 @@ +/** + * Cross-version model compatibility checks. + * + * One divergence matters enough to have its own module, because it is silent, it only + * shows up in game, and it looks like Ella losing the model rather than like a model + * problem. + * + * **A model that declares `parent` has its own `elements` ignored on 1.8.x.** + * + * `ModelBlock.getElements()` on 1.8.9 reads, in full: + * + * ```java + * return this.hasParent() ? this.parent.getElements() : this.elements; + * ``` + * + * with `hasParent()` being nothing more than `parent != null`. The parent wins + * unconditionally. That is why vanilla 1.8.9's own `block/cube.json` declares no parent at + * all and inlines its elements — the file only gained `"parent": "block/block"` in 1.9, + * when the semantics flipped to "the child's elements win if it has any". + * + * So a model carrying both — which is exactly what Blockbench writes when you add geometry + * to a model that started as `block/cube_all` — renders as its parent on 1.8.x. With the + * texture keys Blockbench also rewrites, the parent's `#all` no longer resolves either, so + * the result is a full cube in the missing-texture checkerboard: the model appears not to + * have loaded, when in fact it loaded and was overruled. + */ + +export interface ParentTrap { + /** The parent that will win over the model's own geometry. */ + parent: string; + /** How many elements are being discarded, for a message worth reading. */ + elementCount: number; +} + +/** The versions whose loader lets a parent override the child's geometry. */ +export const PARENT_OVERRIDES_ELEMENTS_BELOW = '1.9'; + +/** + * Reports a model that will render as its parent instead of as itself. + * + * Returns null for the two shapes that are always fine: a model with a parent and no + * geometry of its own (the normal `cube_all` case, and Ella's own slot redirects), and a + * self-contained model with no parent. + */ +export function findParentTrap(model: unknown): ParentTrap | null { + if (typeof model !== 'object' || model === null) return null; + + const candidate = model as { parent?: unknown; elements?: unknown }; + if (typeof candidate.parent !== 'string' || candidate.parent.length === 0) return null; + if (!Array.isArray(candidate.elements) || candidate.elements.length === 0) return null; + + return { parent: candidate.parent, elementCount: candidate.elements.length }; +} + +/** + * Removes the parent, leaving the model self-contained. + * + * Dropping it is the whole fix and it costs nothing: a model with its own elements never + * needed the parent for geometry, and on every version from 1.9 onwards the parent's + * geometry was already being ignored. Texture variables resolve against the model's own + * map, which Blockbench has written by the time there are elements to resolve them for. + * + * Returns a new object; the input is left alone. + */ +export function withoutParent(model: Record): Record { + const { parent: _dropped, ...rest } = model; + return rest; +} diff --git a/launcher/src/shared/model-preview.ts b/launcher/src/shared/model-preview.ts index c9accc3..6c604c3 100644 --- a/launcher/src/shared/model-preview.ts +++ b/launcher/src/shared/model-preview.ts @@ -1,16 +1,18 @@ /** - * Isometric preview of a Minecraft block model. + * Orthographic preview of a Minecraft block model, from any camera angle. * * A block model is a list of axis-aligned boxes with a texture region per face, so an - * exact isometric render needs no 3D engine: each visible face is a parallelogram, and a - * canvas affine transform maps the texture rectangle onto it. + * exact render needs no 3D engine: each face is a parallelogram on screen, and a canvas + * affine transform maps the texture rectangle onto it. * - * Only the three faces an isometric camera can see are drawn — up, south and east — which - * is what makes the result read as a block rather than a flat sprite. + * The camera is described by two angles — `yaw` around the vertical axis and `pitch` for + * its elevation — so the preview can be turned. Only the faces whose outward normal points + * at the camera are drawn, which is what makes the result read as a solid block rather + * than a flat sprite. The default view is the isometric one this preview has always used. * * Known limits, deliberate rather than accidental: * - element rotations are ignored (vanilla allows one 22.5° step per element) - * - no lighting beyond a fixed shade per face direction + * - no lighting beyond vanilla's fixed shade per face direction * - a model with no `elements` of its own falls back to a full cube, which is what * inheriting `block/cube_all` amounts to */ @@ -32,20 +34,107 @@ export interface ParsedModel { textures: Record; } -/** Faces an isometric view can see, drawn back to front. */ -const VISIBLE_FACES = ['up', 'south', 'east'] as const; -export type VisibleFace = (typeof VISIBLE_FACES)[number]; +type Vec3 = [number, number, number]; + +/** The six faces of a box. */ +const FACES = ['up', 'down', 'north', 'south', 'east', 'west'] as const; +export type Face = (typeof FACES)[number]; + +/** Outward normal per face, in model space: x east, y up, z south. */ +const FACE_NORMAL: Record = { + up: [0, 1, 0], + down: [0, -1, 0], + north: [0, 0, -1], + south: [0, 0, 1], + east: [1, 0, 0], + west: [-1, 0, 0], +}; /** - * Fixed shade per face, standing in for Minecraft's own directional lighting. The values - * match vanilla's relative face brightness closely enough to read correctly. + * Shade per face, standing in for Minecraft's own directional lighting. The values are + * vanilla's: brightness depends on which way a face points, not on where the camera is, so + * turning the model shades the newly revealed faces the same way the game would. */ -const FACE_SHADE: Record = { +const FACE_SHADE: Record = { up: 1, + down: 0.5, + north: 0.8, south: 0.8, east: 0.6, + west: 0.6, }; +/** Camera angles, in radians. */ +export interface ViewAngles { + /** Rotation about the vertical axis. At 0 the camera faces the model's south side. */ + yaw: number; + /** Camera elevation. Positive looks down on the model, negative looks up at it. */ + pitch: number; +} + +/** + * The isometric view: yaw 45°, pitch 35.26°. Chosen so the three visible faces are equal + * on screen, which is the angle Minecraft's own inventory render uses. + */ +export const DEFAULT_VIEW: ViewAngles = { yaw: Math.PI / 4, pitch: Math.atan(Math.SQRT1_2) }; + +/** Straight down and straight up: past these the model would turn inside out. */ +export const MAX_PITCH = Math.PI / 2; + +/** + * Uniform zoom baked into the projection, picked so the default view reproduces the plain + * 2:1 isometric formula this preview used before it could rotate. + */ +const VIEW_SCALE = Math.sqrt(1.5); + +/** Fraction of the canvas the model is allowed to fill. The rest is breathing room. */ +const FIT_MARGIN = 0.92; + +/** Projects model space (0..16, y up) to 2D screen space under the given camera. */ +export function project( + x: number, + y: number, + z: number, + scale: number, + view: ViewAngles = DEFAULT_VIEW, +): [number, number] { + const cosYaw = Math.cos(view.yaw); + const sinYaw = Math.sin(view.yaw); + const cosPitch = Math.cos(view.pitch); + const sinPitch = Math.sin(view.pitch); + const k = scale * VIEW_SCALE; + + // Yaw turns the two horizontal axes into a screen-right component and a depth component; + // pitch then trades that depth against height for the vertical screen axis. + return [ + (x * cosYaw - z * sinYaw) * k, + ((x * sinYaw + z * cosYaw) * sinPitch - y * cosPitch) * k, + ]; +} + +/** Unit vector pointing from the model towards the camera. */ +export function viewVector(view: ViewAngles = DEFAULT_VIEW): Vec3 { + const cosPitch = Math.cos(view.pitch); + return [Math.sin(view.yaw) * cosPitch, Math.sin(view.pitch), Math.cos(view.yaw) * cosPitch]; +} + +/** + * The faces the camera can see. A face exactly edge-on is left out: it would be drawn as a + * zero-width sliver, and the face behind it says the same thing better. + */ +export function visibleFaces(view: ViewAngles = DEFAULT_VIEW): Face[] { + const towardsCamera = viewVector(view); + return FACES.filter((face) => dot(FACE_NORMAL[face], towardsCamera) > 1e-6); +} + +/** Applies a rotation to a view, keeping pitch within the range that stays right-side up. */ +export function turn(view: ViewAngles, yawDelta: number, pitchDelta: number): ViewAngles { + return { + yaw: view.yaw + yawDelta, + pitch: Math.min(MAX_PITCH, Math.max(-MAX_PITCH, view.pitch + pitchDelta)), + }; +} + export function parseModel(raw: unknown): ParsedModel | null { if (typeof raw !== 'object' || raw === null) return null; const model = raw as { elements?: ModelElement[]; textures?: Record }; @@ -70,12 +159,6 @@ export function parseModel(raw: unknown): ParsedModel | null { return elements.length > 0 ? { elements, textures } : null; } -/** Projects model space (0..16, y up) to 2D isometric screen space. */ -export function project(x: number, y: number, z: number, scale: number): [number, number] { - // Standard 2:1 isometric: x and z fan out sideways, y is vertical. - return [(x - z) * scale * 0.866, (x + z) * scale * 0.5 - y * scale]; -} - interface FaceCorners { /** Screen-space quad, ordered to match the texture's top-left, top-right, bottom-left. */ origin: [number, number]; @@ -84,59 +167,164 @@ interface FaceCorners { depth: number; } +interface Box { + x1: number; + y1: number; + z1: number; + x2: number; + y2: number; + z2: number; +} + +/** + * The three model-space corners that frame a face's texture: its origin, then the far end + * of the texture's u axis and of its v axis. + * + * The choice per face is vanilla's own UV convention — looking straight at a face, u runs + * right and v runs down. Getting this wrong is invisible on a fixed camera but obvious once + * the model turns, because a face and its opposite would then mirror each other. + */ +const FACE_CORNERS: Record [Vec3, Vec3, Vec3]> = { + up: ({ x1, y2, z1, x2, z2 }) => [ + [x1, y2, z1], + [x2, y2, z1], + [x1, y2, z2], + ], + down: ({ x1, y1, z1, x2, z2 }) => [ + [x1, y1, z2], + [x2, y1, z2], + [x1, y1, z1], + ], + north: ({ x1, y1, z1, x2, y2 }) => [ + [x2, y2, z1], + [x1, y2, z1], + [x2, y1, z1], + ], + south: ({ x1, y1, x2, y2, z2 }) => [ + [x1, y2, z2], + [x2, y2, z2], + [x1, y1, z2], + ], + east: ({ y1, z1, x2, y2, z2 }) => [ + [x2, y2, z2], + [x2, y2, z1], + [x2, y1, z2], + ], + west: ({ x1, y1, z1, y2, z2 }) => [ + [x1, y2, z1], + [x1, y2, z2], + [x1, y1, z1], + ], +}; + /** * Screen-space geometry for one face of one box. * - * `edgeU` and `edgeV` span the face from `origin`, which is exactly what an affine - * texture map needs. + * `edgeU` and `edgeV` span the face from `origin`, which is exactly what an affine texture + * map needs. `depth` measures how near the camera the face is, for the draw order. */ export function faceGeometry( element: { from: number[]; to: number[] }, - face: VisibleFace, + face: Face, scale: number, + view: ViewAngles = DEFAULT_VIEW, ): FaceCorners { - const [x1, y1, z1] = element.from; - const [x2, y2, z2] = element.to; - - const at = (x: number, y: number, z: number): [number, number] => project(x, y, z, scale); - - switch (face) { - case 'up': - return { - origin: at(x1, y2, z1), - edgeU: sub(at(x2, y2, z1), at(x1, y2, z1)), - edgeV: sub(at(x1, y2, z2), at(x1, y2, z1)), - depth: y2 + (x1 + z1) * 0.001, - }; - case 'south': - return { - origin: at(x1, y2, z2), - edgeU: sub(at(x2, y2, z2), at(x1, y2, z2)), - edgeV: sub(at(x1, y1, z2), at(x1, y2, z2)), - depth: z2 + (x1 + y1) * 0.001, - }; - case 'east': - return { - origin: at(x2, y2, z1), - edgeU: sub(at(x2, y2, z2), at(x2, y2, z1)), - edgeV: sub(at(x2, y1, z1), at(x2, y2, z1)), - depth: x2 + (y1 + z1) * 0.001, - }; - } + const [origin, uEnd, vEnd] = FACE_CORNERS[face](boxOf(element)); + + const projected = (point: Vec3): [number, number] => + project(point[0], point[1], point[2], scale, view); + + const screenOrigin = projected(origin); + + return { + origin: screenOrigin, + edgeU: sub(projected(uEnd), screenOrigin), + edgeV: sub(projected(vEnd), screenOrigin), + // The face's centre is the midpoint of the u-v diagonal; how far along the view + // direction it sits is what orders one face against another. + depth: dot(midpoint(uEnd, vEnd), viewVector(view)), + }; } -const sub = (a: [number, number], b: [number, number]): [number, number] => [ - a[0] - b[0], - a[1] - b[1], -]; +/** Draw order: far faces first, so nearer boxes paint over them. */ +export function drawOrder( + model: ParsedModel, + scale: number, + view: ViewAngles = DEFAULT_VIEW, +): Array<{ element: ParsedModel['elements'][number]; face: Face; geometry: FaceCorners }> { + const faces: Array<{ + element: ParsedModel['elements'][number]; + face: Face; + geometry: FaceCorners; + }> = []; -/** Bounding box of a model in screen space, used to centre and fit the drawing. */ -export function screenBounds(model: ParsedModel, scale: number) { + for (const element of model.elements) { + for (const face of visibleFaces(view)) { + faces.push({ element, face, geometry: faceGeometry(element, face, scale, view) }); + } + } + + return faces.sort((a, b) => a.geometry.depth - b.geometry.depth); +} + +/** Bounding box of a model in screen space, under one camera angle. */ +export function screenBounds(model: ParsedModel, scale: number, view: ViewAngles = DEFAULT_VIEW) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; + for (const corner of corners(model)) { + const [sx, sy] = project(corner[0], corner[1], corner[2], scale, view); + minX = Math.min(minX, sx); + minY = Math.min(minY, sy); + maxX = Math.max(maxX, sx); + maxY = Math.max(maxY, sy); + } + + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; +} + +/** Model-space centre of the whole model: the point a rotation turns about. */ +export function modelCentre(model: ParsedModel): Vec3 { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + + for (const corner of corners(model)) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], corner[axis]); + max[axis] = Math.max(max[axis], corner[axis]); + } + } + + return [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]; +} + +/** + * Screen-space radius, at scale 1, that holds the model whichever way it is turned. + * + * Fitting to the bounding box of the current angle instead would be tighter, but the model + * would then swell and shrink as it rotates, which reads as the preview being broken. The + * projection is an orthographic view of a rigid rotation, so the sphere around the model + * projects to a circle of this radius at every angle. + */ +export function viewRadius(model: ParsedModel): number { + const centre = modelCentre(model); + let radius = 0; + + for (const corner of corners(model)) { + radius = Math.max(radius, Math.hypot(...sub3(corner, centre))); + } + + return radius * VIEW_SCALE; +} + +/** Scale that fits the model to a square canvas of `size`, at any camera angle. */ +export function fitScale(model: ParsedModel, size: number): number { + return (size * FIT_MARGIN) / Math.max(viewRadius(model) * 2, 1); +} + +function* corners(model: ParsedModel): Generator { for (const element of model.elements) { const [x1, y1, z1] = element.from; const [x2, y2, z2] = element.to; @@ -144,30 +332,35 @@ export function screenBounds(model: ParsedModel, scale: number) { for (const x of [x1, x2]) { for (const y of [y1, y2]) { for (const z of [z1, z2]) { - const [sx, sy] = project(x, y, z, scale); - minX = Math.min(minX, sx); - minY = Math.min(minY, sy); - maxX = Math.max(maxX, sx); - maxY = Math.max(maxY, sy); + yield [x, y, z]; } } } } - - return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; } -/** Draw order: far faces first, so nearer boxes paint over them. */ -export function drawOrder(model: ParsedModel, scale: number) { - const faces: Array<{ element: ParsedModel['elements'][number]; face: VisibleFace; geometry: FaceCorners }> = []; +const boxOf = (element: { from: number[]; to: number[] }): Box => ({ + x1: element.from[0], + y1: element.from[1], + z1: element.from[2], + x2: element.to[0], + y2: element.to[1], + z2: element.to[2], +}); - for (const element of model.elements) { - for (const face of VISIBLE_FACES) { - faces.push({ element, face, geometry: faceGeometry(element, face, scale) }); - } - } +const sub = (a: [number, number], b: [number, number]): [number, number] => [ + a[0] - b[0], + a[1] - b[1], +]; - return faces.sort((a, b) => a.geometry.depth - b.geometry.depth); -} +const sub3 = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + +const midpoint = (a: Vec3, b: Vec3): Vec3 => [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + (a[2] + b[2]) / 2, +]; + +const dot = (a: Vec3, b: Vec3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; -export { FACE_SHADE, VISIBLE_FACES }; +export { FACE_SHADE, FACES }; diff --git a/launcher/src/shared/project.ts b/launcher/src/shared/project.ts index ca7648a..e435f00 100644 --- a/launcher/src/shared/project.ts +++ b/launcher/src/shared/project.ts @@ -34,11 +34,35 @@ export interface EllaProject { formatVersion: number; name: string; namespace: string; - targetVersions: string[]; + /** + * The Minecraft version this project is authored against, or null until a launch binds + * one. It is what the launcher preselects when the project is opened, and what a launch + * on any other version is checked against — see shared/version-compat.ts. + */ + targetVersion: string | null; slotPool: Record; entries: ProjectEntry[]; } +/** + * Fills in a manifest read from disk. + * + * `targetVersion` replaced a `targetVersions` array that nothing ever read or wrote past + * creation. Projects written before the change carry the array, so its first entry is + * adopted rather than discarded: it was the version the author picked when they created + * the project, which is exactly what the field now means. + */ +export function withDefaults(project: EllaProject): EllaProject { + if (typeof project.targetVersion === 'string' || project.targetVersion === null) { + return project; + } + + const legacy = (project as { targetVersions?: unknown }).targetVersions; + const adopted = Array.isArray(legacy) && typeof legacy[0] === 'string' ? legacy[0] : null; + + return { ...project, targetVersion: adopted }; +} + // --------------------------------------------------------------------------- // Identifier rules // --------------------------------------------------------------------------- @@ -71,7 +95,7 @@ export function emptyProject(name: string, namespace: string): EllaProject { formatVersion: PROJECT_FORMAT_VERSION, name, namespace, - targetVersions: [], + targetVersion: null, slotPool: { block: 128, item: 128 }, entries: [], }; diff --git a/launcher/src/shared/version-compat.ts b/launcher/src/shared/version-compat.ts new file mode 100644 index 0000000..bf645a5 --- /dev/null +++ b/launcher/src/shared/version-compat.ts @@ -0,0 +1,240 @@ +/** + * What moving a project from one Minecraft version to another does to the files in it. + * + * Most of a resource pack is version-independent, and deliberately so: an element's + * geometry, its UVs, its display transforms and a project's references to its *own* + * textures have read the same way from 1.8 to 1.21. Two things have not, and both fail + * silently — the game loads the file, reports nothing, and draws the wrong thing: + * + * - **A `parent` overrides the model's own geometry below 1.9.** The model renders as + * whatever it inherits from. See model-compat.ts for why, in full. + * - **Vanilla's texture folders were renamed in 1.13.** `textures/blocks` became + * `textures/block` and `textures/items` became `textures/item`, so a model that borrows + * a vanilla texture — `minecraft:blocks/stone`, which is what Blockbench writes when + * you pick one on 1.12 — resolves on one side of that line and shows the missing-texture + * checkerboard on the other. + * + * A project's own textures are deliberately left alone. A resource pack may keep them under + * any folder it likes: the model's reference *is* the path, and Ella always writes + * `:block/` with a file to match, which resolves identically on every + * version. "Fixing" those to follow vanilla's rename would break the one thing that already + * works everywhere. + * + * Everything here is pure. Reading the files and writing them back is main/version-change.ts. + */ + +import { baseVersionOf, isAtLeast, isBelow } from './version.ts'; +import { + findParentTrap, + withoutParent, + PARENT_OVERRIDES_ELEMENTS_BELOW, +} from './model-compat.ts'; + +/** The release that renamed vanilla's `textures/blocks` and `textures/items` folders. */ +export const VANILLA_FOLDER_RENAME = '1.13'; + +export type CompatIssueId = 'parentOverridesGeometry' | 'vanillaTextureFolder'; + +export interface CompatIssue { + id: CompatIssueId; + /** True when {@link migrateModel} can rewrite the model itself. */ + fixable: boolean; + /** Values for the message describing it. */ + detail: Record; +} + +// --------------------------------------------------------------------------- +// Vanilla texture references +// --------------------------------------------------------------------------- + +/** Folder each era wants, keyed by the name the other era used. */ +const FOLDER_FOR: Record<'modern' | 'legacy', Record> = { + modern: { blocks: 'block', items: 'item' }, + legacy: { block: 'blocks', item: 'items' }, +}; + +const eraOf = (target: string): 'modern' | 'legacy' => + isAtLeast(baseVersionOf(target), VANILLA_FOLDER_RENAME) ? 'modern' : 'legacy'; + +/** The folders vanilla keeps its block and item textures in, on a given version. */ +export function vanillaTextureFolders(target: string): { block: string; item: string } { + return eraOf(target) === 'modern' + ? { block: 'block', item: 'item' } + : { block: 'blocks', item: 'items' }; +} + +/** + * The same texture reference written the way `target` expects it, or null when it already + * is — or when it is not a vanilla block/item texture at all. + * + * `#name` is a reference to another texture variable rather than to a file, and anything + * outside the `minecraft` namespace belongs to a pack that decides its own layout. + */ +export function retargetVanillaTexture(reference: string, target: string): string | null { + if (reference.startsWith('#')) return null; + + const colon = reference.indexOf(':'); + // An unqualified reference means vanilla, which is exactly the case that needs fixing. + const namespace = colon === -1 ? 'minecraft' : reference.slice(0, colon); + const location = colon === -1 ? reference : reference.slice(colon + 1); + if (namespace !== 'minecraft') return null; + + const slash = location.indexOf('/'); + if (slash === -1) return null; + + const wanted = FOLDER_FOR[eraOf(target)][location.slice(0, slash)]; + if (!wanted) return null; + + const rewritten = `${wanted}/${location.slice(slash + 1)}`; + return colon === -1 ? rewritten : `minecraft:${rewritten}`; +} + +/** Every texture variable in a model whose vanilla reference is written for the wrong era. */ +function staleVanillaTextures( + model: unknown, + target: string, +): Array<{ key: string; from: string; to: string }> { + const textures = (model as { textures?: unknown }).textures; + if (typeof textures !== 'object' || textures === null) return []; + + const stale: Array<{ key: string; from: string; to: string }> = []; + for (const [key, value] of Object.entries(textures as Record)) { + if (typeof value !== 'string') continue; + const retargeted = retargetVanillaTexture(value, target); + if (retargeted) stale.push({ key, from: value, to: retargeted }); + } + return stale; +} + +// --------------------------------------------------------------------------- +// Inspection and migration +// --------------------------------------------------------------------------- + +/** Everything about one model that would go wrong on `target`. */ +export function inspectModel(model: unknown, target: string): CompatIssue[] { + if (typeof model !== 'object' || model === null) return []; + + const issues: CompatIssue[] = []; + const version = baseVersionOf(target); + + const trap = findParentTrap(model); + if (trap && isBelow(version, PARENT_OVERRIDES_ELEMENTS_BELOW)) { + issues.push({ id: 'parentOverridesGeometry', fixable: true, detail: { parent: trap.parent } }); + } + + const stale = staleVanillaTextures(model, version); + if (stale.length > 0) { + // One issue per model rather than per reference: they are the same mistake made once, + // they are fixed in the same pass, and a row each would bury the rest of the report. + // `count` carries the rest, so the UI can say how many without listing them. + issues.push({ + id: 'vanillaTextureFolder', + fixable: true, + detail: { count: stale.length, reference: stale[0].from, expected: stale[0].to }, + }); + } + + return issues; +} + +/** + * Rewrites a model so it renders on `target`, returning a new object. + * + * Both rewrites are safe in either direction and on any version: dropping a parent leaves a + * model that already had its own geometry self-contained, and a vanilla texture reference + * has exactly one correct spelling per era. That is what makes offering this at launch + * reasonable rather than reckless — nothing here is a guess about intent. + */ +export function migrateModel( + model: Record, + target: string, +): { model: Record; applied: CompatIssueId[] } { + let next = model; + const applied: CompatIssueId[] = []; + + for (const issue of inspectModel(model, target)) { + if (!issue.fixable) continue; + + if (issue.id === 'parentOverridesGeometry') { + next = withoutParent(next); + applied.push(issue.id); + } + + if (issue.id === 'vanillaTextureFolder') { + const textures = { ...(next.textures as Record) }; + for (const { key, to } of staleVanillaTextures(next, baseVersionOf(target))) { + textures[key] = to; + } + next = { ...next, textures }; + applied.push(issue.id); + } + } + + return { model: next, applied }; +} + +// --------------------------------------------------------------------------- +// Consequences that are not about the project's files +// --------------------------------------------------------------------------- + +export type CompatNoteId = + | 'notInstalled' + | 'noAdapter' + | 'plannedAdapter' + | 'javaMissing' + | 'losesLiveEditing'; + +export interface CompatNote { + id: CompatNoteId; + detail: Record; +} + +/** The part of a version summary these notes are drawn from. */ +export interface VersionFacts { + id: string; + installed: boolean; + adapterStatus: 'built' | 'planned' | null; + javaAvailable: boolean; + requiredJava: number; +} + +/** + * What launching `to` costs, beyond the files. Every note is a warning — one that was not + * would have no business in a confirmation dialog. + * + * `from` is the version the project is bound to, and is only used to say what is being + * given up: losing live editing matters far more to someone who had it a moment ago than + * to someone who never did. + */ +export function versionChangeNotes(from: VersionFacts | null, to: VersionFacts): CompatNote[] { + const notes: CompatNote[] = []; + + if (!to.installed) { + notes.push({ id: 'notInstalled', detail: { version: to.id } }); + } + + if (to.adapterStatus === null) { + notes.push({ id: 'noAdapter', detail: { version: to.id } }); + } else if (to.adapterStatus === 'planned') { + notes.push({ id: 'plannedAdapter', detail: { version: to.id } }); + } + + if (from?.adapterStatus === 'built' && to.adapterStatus !== 'built') { + notes.push({ id: 'losesLiveEditing', detail: { from: from.id, to: to.id } }); + } + + if (!to.javaAvailable) { + notes.push({ id: 'javaMissing', detail: { version: to.id, java: to.requiredJava } }); + } + + return notes; +} + +/** + * Whether launching `to` is a version change worth stopping for. + * + * An unbound project is not: the first launch is what binds it, and asking someone to + * confirm a change away from nothing would be a dialog with no decision in it. + */ +export const isVersionChange = (from: string | null, to: string): boolean => + from !== null && from !== to; diff --git a/launcher/src/shared/version.ts b/launcher/src/shared/version.ts index 942cf56..6a6a736 100644 --- a/launcher/src/shared/version.ts +++ b/launcher/src/shared/version.ts @@ -189,7 +189,16 @@ export interface AdapterCoverage { * "compiles for 1.21.1" says nothing about 1.21.11. */ export const ADAPTERS: AdapterCoverage[] = [ - { id: 'forge-1.8.9', min: '1.8', max: '1.9', status: 'planned' }, + /* + * Starts at 1.8.8, not 1.8. + * + * The jar is compiled against 1.8.9 and reobfuscated to SRG names, so what it covers is + * decided by the mappings rather than by the version number. Every method and field the + * adapter overrides maps to the same obfuscated target in 1.8.8 and 1.8.9 — checked + * against both `joined.srg` files, not assumed from the versions being adjacent. In 1.8 + * itself every one of them differs, which is why the range stops short of it. + */ + { id: 'forge-1.8.9', min: '1.8.8', max: '1.9', status: 'built' }, { id: 'forge-1.12.2', min: '1.12', max: '1.13', status: 'built' }, { id: 'forge-mid', min: '1.16', max: '1.20.2', status: 'planned' }, { id: 'forge-modern', min: '1.21.1', max: '1.21.2', status: 'built' }, diff --git a/launcher/src/shared/workflow.ts b/launcher/src/shared/workflow.ts new file mode 100644 index 0000000..5ea74f5 --- /dev/null +++ b/launcher/src/shared/workflow.ts @@ -0,0 +1,72 @@ +/** + * The setup path, derived from live state rather than remembered. + * + * Ella's loop only works once five things are true at the same time, and the order they + * have to happen in is not guessable from the navigation: a version installed, a project + * open, something in it, Blockbench reachable, and the game running. Nothing here is a + * checkbox the user ticks — every step reads the same state the rest of the UI reads, so + * it can never claim something is done when it is not, and a step that stops being true + * (the game exits, the project is closed) goes back to undone on its own. + * + * Steps are never hidden once complete. Seeing the whole path, with the finished part + * behind you, is what makes the order obvious the first time and reassuring after that. + */ + +export type WorkflowStepId = 'version' | 'project' | 'entry' | 'blockbench' | 'launch'; + +/** Everything the path depends on, flattened out of the session and config. */ +export interface WorkflowFacts { + installedVersions: number; + /** Installed versions whose adapter jar exists — the only ones that can live-edit. */ + liveEditingVersions: number; + hasProject: boolean; + entryCount: number; + /** Whether Ella can find a Blockbench to launch, configured or auto-detected. */ + blockbenchFound: boolean; + /** The mod has completed its handshake, so edits reach the game. */ + connected: boolean; +} + +export interface WorkflowStep { + id: WorkflowStepId; + done: boolean; + /** The first unfinished step: the one thing to do next, and the only accented control. */ + current: boolean; +} + +/** Fixed order — this is the sequence, not a set of independent chores. */ +const ORDER: WorkflowStepId[] = ['version', 'project', 'entry', 'blockbench', 'launch']; + +export function workflowSteps(facts: WorkflowFacts): WorkflowStep[] { + const done: Record = { + version: facts.installedVersions > 0, + project: facts.hasProject, + entry: facts.hasProject && facts.entryCount > 0, + blockbench: facts.blockbenchFound, + launch: facts.connected, + }; + + // Exactly one step is current, and only the earliest unfinished one. Marking every + // unfinished step would be the same as marking none. + const first = ORDER.find((id) => !done[id]); + + return ORDER.map((id) => ({ id, done: done[id], current: id === first })); +} + +export const isSetupComplete = (steps: WorkflowStep[]): boolean => + steps.every((step) => step.done); + +export const currentStep = (steps: WorkflowStep[]): WorkflowStep | null => + steps.find((step) => step.current) ?? null; + +export const completedCount = (steps: WorkflowStep[]): number => + steps.filter((step) => step.done).length; + +/** + * Whether the versions on this machine can live-edit at all. + * + * Distinct from having no version installed: someone can install 1.19.2, see everything + * work, and never understand why edits do not appear. Naming it as its own condition lets + * the UI say so before the launch rather than after. + */ +export const canLiveEdit = (facts: WorkflowFacts): boolean => facts.liveEditingVersions > 0; diff --git a/launcher/test/adapter-ranges.test.ts b/launcher/test/adapter-ranges.test.ts new file mode 100644 index 0000000..bd4d7fc --- /dev/null +++ b/launcher/test/adapter-ranges.test.ts @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ADAPTERS, type AdapterId } from '../src/shared/version.ts'; + +/* + * Keeps ADAPTERS in step with what each adapter tells its own loader. + * + * The README claims this table prevents the launcher offering a version the game then + * refuses the mod on. It did not: widening the 1.8 bucket to 1.8.8 left the mod still + * declaring 1.8.9 to FML, and the game rejected it with "Ella (ella) wants Minecraft + * [1.8.9,1.8.9]" on a version the launcher had just advertised as live-editable. Nothing + * in the build could catch that, because the two facts live in different languages in + * different directories. + * + * Read as text rather than parsed: these are Java and TOML, and a regex over a line that + * has to be written literally anyway is enough to notice it changing. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** Where each built adapter states the range it accepts, and how to find it in the file. */ +const DECLARATIONS: Record = { + 'forge-1.8.9': { + file: 'mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java', + pattern: /acceptedMinecraftVersions\s*=\s*"([^"]+)"/, + }, + 'forge-1.12.2': { + file: 'mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java', + pattern: /acceptedMinecraftVersions\s*=\s*"([^"]+)"/, + }, + 'forge-modern': { + file: 'mod/adapters/forge-modern/src/main/resources/META-INF/mods.toml', + // The minecraft dependency's range, not the loader's. + pattern: /versionRange\s*=\s*"(\[1\.[^"]+)"/, + }, +}; + +/** ADAPTERS uses an inclusive minimum and an exclusive maximum, which is Maven's `[a,b)`. */ +const expectedRange = (min: string, max: string): string => `[${min},${max})`; + +for (const coverage of ADAPTERS.filter((entry) => entry.status === 'built')) { + test(`${coverage.id} declares the range the launcher advertises`, async () => { + const declaration = DECLARATIONS[coverage.id as AdapterId]; + assert.ok(declaration, `no declaration site recorded for ${coverage.id}`); + + const source = await readFile(path.join(repoRoot, declaration.file), 'utf8'); + const match = declaration.pattern.exec(source); + + assert.ok( + match, + `${declaration.file} states no accepted Minecraft range, so the loader will infer ` + + 'one from the exact version and refuse everything else in the bucket', + ); + + assert.equal( + match[1], + expectedRange(coverage.min, coverage.max), + `${coverage.id} accepts ${match[1]} but the launcher offers ` + + `${expectedRange(coverage.min, coverage.max)}`, + ); + }); +} + +test('every built adapter has a declaration site to check', () => { + // A new adapter must not pass this file silently by being absent from it. + for (const coverage of ADAPTERS.filter((entry) => entry.status === 'built')) { + assert.ok( + coverage.id in DECLARATIONS, + `${coverage.id} is built but this test does not know where it declares its range`, + ); + } +}); diff --git a/launcher/test/app.test.ts b/launcher/test/app.test.ts new file mode 100644 index 0000000..35dae45 --- /dev/null +++ b/launcher/test/app.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { APP_NAME, APP_VERSION } from '../src/shared/app.ts'; + +test('the version shown in the app is the version that was built', async () => { + // APP_VERSION is written out rather than read from the manifest, because the renderer has + // no filesystem. This is what stops the two drifting: a release bumps package.json, and + // this fails until the constant follows — before a build ships a sidebar and a crash + // report claiming the wrong build. + const manifest = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ) as { name: string; version: string }; + + assert.equal(APP_VERSION, manifest.version); +}); + +test('the version is a version', async () => { + assert.match(APP_VERSION, /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/); +}); + +test('the launcher names itself the same way everywhere', () => { + // Minecraft is told this name in its launch arguments and it appears in the window, so a + // stray rename in one place would show up in a log nobody thinks to distrust. + assert.equal(APP_NAME, 'Ella'); +}); diff --git a/launcher/test/forge-install.test.ts b/launcher/test/forge-install.test.ts new file mode 100644 index 0000000..3e4844d --- /dev/null +++ b/launcher/test/forge-install.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isLegacyProfile } from '../src/main/minecraft/forge.ts'; + +/* + * The two installer generations, as they actually appear on maven.minecraftforge.net. + * + * Forge builds before 2018 have no `--installClient`: passing it aborts with + * "'installClient' is not a recognized option", which is what made every 1.8.x install + * fail while still reporting the version as installed. Those builds also predate the + * install processors, so unpacking them directly is complete rather than a shortcut. + * + * The fields below are trimmed from the real profiles of forge 11.15.0.1655 (1.8.8) and + * 14.23.5.2859 (1.12.2). + */ + +const LEGACY = { + install: { + profileName: 'forge', + target: '1.8.8-forge1.8.8-11.15.0.1655', + path: 'net.minecraftforge:forge:1.8.8-11.15.0.1655', + filePath: 'forge-1.8.8-11.15.0.1655-universal.jar', + minecraft: '1.8.8', + }, + versionInfo: { + id: '1.8.8-forge1.8.8-11.15.0.1655', + inheritsFrom: '1.8.8', + mainClass: 'net.minecraft.launchwrapper.Launch', + libraries: [], + }, +}; + +const MODERN = { + spec: 0, + profile: 'forge', + version: '1.12.2-forge-14.23.5.2859', + json: '/version.json', + path: 'net.minecraftforge:forge:1.12.2-14.23.5.2859', + minecraft: '1.12.2', + data: {}, + processors: [{ jar: 'net.minecraftforge:installertools:1.1.6', args: [] }], + libraries: [], +}; + +test('the pre-2018 installer layout is recognised', () => { + assert.equal(isLegacyProfile(LEGACY), true); +}); + +test('a processor-driven installer is left to the official installer', () => { + // Unpacking one of these by hand would skip the binary patching and deobfuscation, and + // produce a version that installs cleanly then crashes on launch. + assert.equal(isLegacyProfile(MODERN), false); +}); + +test('anything unreadable falls through to the installer', () => { + assert.equal(isLegacyProfile(null), false); + assert.equal(isLegacyProfile(undefined), false); + assert.equal(isLegacyProfile('install_profile.json'), false); + assert.equal(isLegacyProfile({}), false); +}); + +test('a half-formed legacy profile is not treated as installable', () => { + // Every field is used during the unpack, so a missing one has to disqualify the whole + // profile rather than fail partway through with files already written. + assert.equal(isLegacyProfile({ install: LEGACY.install }), false); + assert.equal(isLegacyProfile({ versionInfo: LEGACY.versionInfo }), false); + assert.equal( + isLegacyProfile({ install: { path: LEGACY.install.path }, versionInfo: LEGACY.versionInfo }), + false, + 'a profile with no universal jar to file away', + ); + assert.equal( + isLegacyProfile({ install: LEGACY.install, versionInfo: { inheritsFrom: '1.8.8' } }), + false, + 'a profile with no version id to install under', + ); +}); diff --git a/launcher/test/model-compat.test.ts b/launcher/test/model-compat.test.ts new file mode 100644 index 0000000..37b4929 --- /dev/null +++ b/launcher/test/model-compat.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { findParentTrap, withoutParent } from '../src/shared/model-compat.ts'; +import { defaultBlockModel } from '../src/main/pack.ts'; + +/* + * The failure these guard, in full. + * + * 1.8.9's ModelBlock.getElements() is `hasParent() ? parent.getElements() : elements`, + * with hasParent() being `parent != null`. The parent wins outright — which is why vanilla + * 1.8.9's own block/cube.json declares no parent and inlines its elements, and only gained + * `"parent": "block/block"` in 1.9 once the child started winning. + * + * Blockbench keeps whatever parent it finds. A model that starts as `cube_all` and then + * gains geometry therefore renders in game as a plain cube in the missing-texture + * checkerboard: the author's shapes are discarded, and the parent's `#all` no longer + * resolves because Blockbench rewrote the texture keys. + */ + +const BLOCKBENCH_OUTPUT = { + format_version: '1.21.11', + parent: 'block/cube_all', + textures: { 0: 'proj:block/thing', particle: 'proj:block/thing' }, + elements: [{ from: [0, 5, 7], to: [9, 7, 9], faces: {} }], +}; + +test('a parent over the model’s own geometry is reported', () => { + const trap = findParentTrap(BLOCKBENCH_OUTPUT); + assert.deepEqual(trap, { parent: 'block/cube_all', elementCount: 1 }); +}); + +test('inheriting geometry is normal and not reported', () => { + // The everyday case: no elements of its own, so the parent is doing its job. + assert.equal(findParentTrap({ parent: 'block/cube_all', textures: { all: 'a:b' } }), null); + // Ella's own slot redirect is exactly this shape and must never be flagged. + assert.equal(findParentTrap({ parent: 'proj:block/thing' }), null); +}); + +test('a self-contained model is not reported', () => { + assert.equal(findParentTrap({ elements: [{ from: [0, 0, 0], to: [16, 16, 16] }] }), null); +}); + +test('malformed input is not mistaken for a trap', () => { + for (const value of [null, undefined, 'model', 42, [], {}]) { + assert.equal(findParentTrap(value), null, `${JSON.stringify(value)} should be ignored`); + } + // An empty elements array means the parent still supplies the geometry. + assert.equal(findParentTrap({ parent: 'block/cube_all', elements: [] }), null); + assert.equal(findParentTrap({ parent: '', elements: [{}] }), null); +}); + +test('the fix removes only the parent', () => { + const fixed = withoutParent(BLOCKBENCH_OUTPUT); + + assert.equal('parent' in fixed, false); + assert.equal(findParentTrap(fixed), null, 'the fixed model must not trip the check again'); + assert.deepEqual(fixed.elements, BLOCKBENCH_OUTPUT.elements); + assert.deepEqual(fixed.textures, BLOCKBENCH_OUTPUT.textures); + assert.equal(fixed.format_version, '1.21.11', 'unrelated keys Blockbench needs stay'); + assert.equal('parent' in BLOCKBENCH_OUTPUT, true, 'the input is not mutated'); +}); + +test('the generated starting model cannot inherit the trap', () => { + // A new block starts self-contained precisely so Blockbench has no parent to keep. + const model = JSON.parse(defaultBlockModel('proj:block/thing')); + + assert.equal('parent' in model, false); + assert.ok(model.elements.length > 0, 'it has to draw something on its own'); + assert.equal(findParentTrap(model), null); + + // Still a full cube, which is what inheriting from cube_all used to provide. + assert.deepEqual(model.elements[0].from, [0, 0, 0]); + assert.deepEqual(model.elements[0].to, [16, 16, 16]); + assert.equal(model.textures.all, 'proj:block/thing'); + assert.equal(model.textures.particle, 'proj:block/thing', 'break particles need a texture'); +}); diff --git a/launcher/test/model-preview.test.ts b/launcher/test/model-preview.test.ts index 62024dc..ab7ae8f 100644 --- a/launcher/test/model-preview.test.ts +++ b/launcher/test/model-preview.test.ts @@ -6,7 +6,15 @@ import { faceGeometry, screenBounds, drawOrder, + visibleFaces, + viewRadius, + fitScale, + modelCentre, + turn, + DEFAULT_VIEW, + MAX_PITCH, FACE_SHADE, + type ViewAngles, } from '../src/shared/model-preview.ts'; const CUBE = { from: [0, 0, 0], to: [16, 16, 16], faces: {} }; @@ -104,9 +112,135 @@ test('every visible face of every element is drawn', () => { assert.equal(drawOrder(model, 1).length, 6); }); -test('shading distinguishes the three visible faces', () => { - // Identical shading would make a cube read as a flat hexagon. - const values = Object.values(FACE_SHADE); - assert.equal(new Set(values).size, values.length); +test('shading distinguishes the faces visible at once', () => { + // Identical shading would make a cube read as a flat hexagon. Opposite faces may share a + // shade — vanilla gives them one — because no camera sees both. + const shades = visibleFaces(DEFAULT_VIEW).map((face) => FACE_SHADE[face]); + assert.equal(new Set(shades).size, shades.length); assert.equal(FACE_SHADE.up, 1, 'the lit face is unshaded'); }); + +test('the default view sees the top, south and east faces', () => { + assert.deepEqual(visibleFaces(DEFAULT_VIEW).sort(), ['east', 'south', 'up']); +}); + +test('turning half way round shows the opposite faces', () => { + const behind = turn(DEFAULT_VIEW, Math.PI, 0); + assert.deepEqual(visibleFaces(behind).sort(), ['north', 'up', 'west']); +}); + +test('looking from below swaps the top face for the bottom one', () => { + const under = turn(DEFAULT_VIEW, 0, -2 * DEFAULT_VIEW.pitch); + assert.ok(visibleFaces(under).includes('down')); + assert.ok(!visibleFaces(under).includes('up')); +}); + +test('a face seen exactly edge-on is not drawn', () => { + // At yaw 0 the camera is square on to the south face, so east and west are slivers. + const faces = visibleFaces({ yaw: 0, pitch: DEFAULT_VIEW.pitch }); + assert.deepEqual(faces.sort(), ['south', 'up']); +}); + +test('pitch cannot go past straight up or straight down', () => { + assert.equal(turn(DEFAULT_VIEW, 0, 10).pitch, MAX_PITCH); + assert.equal(turn(DEFAULT_VIEW, 0, -10).pitch, -MAX_PITCH); +}); + +test('yaw is free to wind past a full turn', () => { + // Clamping it would make a drag stick at an arbitrary angle mid-gesture. + const spun = turn(DEFAULT_VIEW, 4 * Math.PI, 0); + assert.ok(Math.abs(spun.yaw - DEFAULT_VIEW.yaw - 4 * Math.PI) < 1e-9); +}); + +test('every visible face of every element is drawn, at any angle', () => { + const model = parseModel({ elements: [CUBE, { from: [0, 0, 0], to: [8, 8, 8], faces: {} }] })!; + const view = turn(DEFAULT_VIEW, 0.7, -0.2); + + assert.equal(drawOrder(model, 1, view).length, visibleFaces(view).length * 2); +}); + +test('draw order stays back to front after a rotation', () => { + const model = parseModel({ + elements: [ + { from: [0, 0, 0], to: [4, 4, 4], faces: {} }, + { from: [12, 12, 12], to: [16, 16, 16], faces: {} }, + ], + })!; + + for (const yaw of [0, 1, 2, 3, 4, 5]) { + const order = drawOrder(model, 1, { yaw, pitch: 0.3 }); + const depths = order.map((face) => face.geometry.depth); + + for (let i = 1; i < depths.length; i++) { + assert.ok(depths[i] >= depths[i - 1], `depths must be non-decreasing at yaw ${yaw}`); + } + } +}); + +test('no face is ever drawn mirrored', () => { + // A face is textured by mapping the unit square onto (edgeU, edgeV); if that pair winds + // the wrong way round, the texture comes out flipped. Invisible on a fixed camera, and + // glaring the moment the model can be turned. + for (const view of samples()) { + for (const face of visibleFaces(view)) { + const { edgeU, edgeV } = faceGeometry(CUBE, face, 1, view); + const determinant = edgeU[0] * edgeV[1] - edgeU[1] * edgeV[0]; + + assert.ok(determinant > 0, `${face} is mirrored at yaw ${view.yaw}, pitch ${view.pitch}`); + } + } +}); + +test('a face and its opposite are not mirror images of each other', () => { + // Both textures are read left to right, so their screen u axes must point opposite ways — + // the bug you only notice once the model can turn. + const east = faceGeometry(CUBE, 'east', 1); + const west = faceGeometry(CUBE, 'west', 1); + + assert.ok(east.edgeU[0] * west.edgeU[0] + east.edgeU[1] * west.edgeU[1] < 0); + + const north = faceGeometry(CUBE, 'north', 1); + const south = faceGeometry(CUBE, 'south', 1); + + assert.ok(north.edgeU[0] * south.edgeU[0] + north.edgeU[1] * south.edgeU[1] < 0); +}); + +test('the model turns about its own centre', () => { + const model = parseModel({ elements: [{ from: [4, 0, 4], to: [12, 6, 12], faces: {} }] })!; + assert.deepEqual(modelCentre(model), [8, 3, 8]); +}); + +test('the fitted size does not change as the model turns', () => { + // A preview that swells and shrinks mid-drag reads as broken, so the fit has to hold the + // model at every angle rather than at the current one. + const model = parseModel({ elements: [CUBE] })!; + const scale = fitScale(model, 100); + + for (const view of samples()) { + const bounds = screenBounds(model, scale, view); + assert.ok(bounds.width <= 100 && bounds.height <= 100, 'the model never leaves the canvas'); + } +}); + +test('the fit is tight enough to be worth the canvas', () => { + const model = parseModel({ elements: [CUBE] })!; + const scale = fitScale(model, 100); + const widest = Math.max(...samples().map((view) => screenBounds(model, scale, view).height)); + + assert.ok(widest > 80, 'some angle fills most of the canvas'); +}); + +test('a flat model fits as well as a bulky one', () => { + const plate = parseModel({ elements: [{ from: [0, 0, 7], to: [16, 16, 9], faces: {} }] })!; + assert.ok(viewRadius(plate) < viewRadius(parseModel({ elements: [CUBE] })!)); +}); + +function samples(): ViewAngles[] { + const views: ViewAngles[] = []; + for (let yaw = 0; yaw < 12; yaw++) { + for (let pitch = -3; pitch <= 3; pitch++) { + views.push({ yaw: (yaw * Math.PI) / 6, pitch: (pitch * MAX_PITCH) / 3 }); + } + } + return views; +} diff --git a/launcher/test/pack-project.test.ts b/launcher/test/pack-project.test.ts index 2ca4ce5..bbca0e2 100644 --- a/launcher/test/pack-project.test.ts +++ b/launcher/test/pack-project.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, rm, readFile, readdir } from 'node:fs/promises'; +import { mkdtemp, rm, readFile, readdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { inflateSync } from 'node:zlib'; import path from 'node:path'; @@ -10,6 +10,7 @@ import { slotRedirectModel, buildLangFiles, writeSlotNamespace, + writeEntrySlot, packMcmeta, fallbackPackFormat, SLOT_NAMESPACE, @@ -254,6 +255,86 @@ test('regenerating the slot namespace removes stale files', async () => { assert.deepEqual(await readdir(blockstates), ['block_000.json']); }); +// --------------------------------------------------------------------------- +// Targeted slot writes +// --------------------------------------------------------------------------- + +/** + * A settings change takes the cheap path: one slot rewritten rather than the whole + * namespace. These pin the two properties that makes safe — it writes the slot it was + * given, and it leaves every other slot exactly as it was. + */ +test('writing one entry slot updates only that slot', async () => { + const root = path.join(workspace, 'targeted'); + const base = { ...emptyProject('P', 'proj'), slotPool: { block: 3, item: 1 } }; + + const project: typeof base = { + ...base, + entries: [ + { + ...base.entries[0], + id: 'lamp', + kind: 'block', + slot: 1, + displayName: { en: 'Lamp' }, + settings: { renderLayer: 'solid' }, + } as (typeof base.entries)[number], + ], + }; + + await writeSlotNamespace(root, project, 34); + + const modelAt = (slot: number): string => + path.join(root, `pack/assets/ella/models/block/slot_${String(slot).padStart(3, '0')}.json`); + + const untouchedBefore = await readFile(modelAt(0), 'utf8'); + + const patched = { + ...project.entries[0], + settings: { renderLayer: 'translucent' }, + }; + const written = await writeEntrySlot(root, project, patched); + + assert.equal(written, true); + assert.equal( + JSON.parse(await readFile(modelAt(1), 'utf8')).render_type, + 'minecraft:translucent', + 'the entry’s own slot must pick up the new render layer', + ); + assert.equal( + await readFile(modelAt(0), 'utf8'), + untouchedBefore, + 'no other slot may be rewritten', + ); + + // Both copies of a block model stay in step — the pair is what lets one blockstate + // serve versions either side of 1.13. + assert.equal( + await readFile(modelAt(1), 'utf8'), + await readFile( + path.join(root, 'pack/assets/ella/models/block/block/slot_001.json'), + 'utf8', + ), + ); +}); + +test('an unbound entry has no slot to write', async () => { + const root = path.join(workspace, 'targeted-unbound'); + const project = { ...emptyProject('P', 'proj'), slotPool: { block: 1, item: 1 } }; + await writeSlotNamespace(root, project, 34); + + const entry = { + id: 'floating', + kind: 'block' as const, + slot: null, + displayName: { en: 'Floating' }, + settings: {}, + model: { source: 'json' as const, path: 'x.json', output: 'x.json' }, + }; + + assert.equal(await writeEntrySlot(root, project, entry), false); +}); + // --------------------------------------------------------------------------- // Identifiers // --------------------------------------------------------------------------- @@ -307,17 +388,41 @@ test('reports an exhausted pool instead of returning a bad slot', () => { // --------------------------------------------------------------------------- test('creates a project on disk and reads it back', async () => { - const { project, root } = await createProject('My Project', 'myproject', ['1.12.2']); + const { project, root } = await createProject('My Project', 'myproject', '1.12.2'); assert.equal(project.namespace, 'myproject'); const reloaded = await loadProject(root); assert.equal(reloaded.name, 'My Project'); - assert.deepEqual(reloaded.targetVersions, ['1.12.2']); + assert.equal(reloaded.targetVersion, '1.12.2'); const mcmeta = JSON.parse(await readFile(path.join(root, 'pack', 'pack.mcmeta'), 'utf8')); assert.ok(mcmeta.pack.pack_format > 0); }); +test('a project written before targetVersion adopts its first old target', async () => { + // `targetVersions` was an array nothing ever read past creation. Its first entry is the + // version the author picked when they created the project, which is exactly what the + // single field now means — discarding it would silently unbind every existing project. + const { root } = await createProject('Legacy', 'legacyproj'); + const manifest = JSON.parse(await readFile(path.join(root, 'project.json'), 'utf8')); + + delete manifest.targetVersion; + manifest.targetVersions = ['1.12.2', '1.21.1']; + await writeFile(path.join(root, 'project.json'), JSON.stringify(manifest, null, 2), 'utf8'); + + assert.equal((await loadProject(root)).targetVersion, '1.12.2'); +}); + +test('a project with no version at all loads unbound rather than failing', async () => { + const { root } = await createProject('Bare', 'bareproj'); + const manifest = JSON.parse(await readFile(path.join(root, 'project.json'), 'utf8')); + + delete manifest.targetVersion; + await writeFile(path.join(root, 'project.json'), JSON.stringify(manifest, null, 2), 'utf8'); + + assert.equal((await loadProject(root)).targetVersion, null); +}); + test('rejects an invalid namespace', async () => { await assert.rejects(createProject('X', 'Bad Namespace'), (error: ProjectError) => { assert.equal(error.code, 'INVALID_NAMESPACE'); @@ -405,7 +510,7 @@ test('deleting an entry keeps its files unless asked otherwise', async () => { }); const modelPath = path.join(root, 'pack/assets/delproj/models/item/gem.json'); - const after = await deleteEntry(root, withEntry, 'gem'); + const { project: after } = await deleteEntry(root, withEntry, 'gem'); assert.equal(after.entries.length, 0); // The author's model is their work; a mis-click must not destroy it. assert.ok(await readFile(modelPath).then(() => true, () => false), 'model still on disk'); diff --git a/launcher/test/session-exit.test.ts b/launcher/test/session-exit.test.ts new file mode 100644 index 0000000..4f0de79 --- /dev/null +++ b/launcher/test/session-exit.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyExit } from '../src/main/session.ts'; + +/* + * Every one of these was a real misreading at some point: killing a process reports a null + * exit code, so a `code !== 0` test treated the Stop button as a crash — an error in the + * log and the crash dialog on screen, for an action the user asked for. + */ + +test('pressing Stop is not a failure', () => { + // Node reports a killed process as (null, 'SIGTERM'), which on its own is + // indistinguishable from being killed by anything else. + assert.deepEqual(classifyExit(true, null, 'SIGTERM'), { kind: 'stopped' }); +}); + +test('a requested stop stays a stop whatever the process reports', () => { + // Windows kills can surface as an exit code rather than a signal. + assert.deepEqual(classifyExit(true, 1, null), { kind: 'stopped' }); + assert.deepEqual(classifyExit(true, 0, null), { kind: 'stopped' }); +}); + +test('quitting from inside the game is silent', () => { + assert.deepEqual(classifyExit(false, 0, null), { kind: 'quit' }); +}); + +test('an outside kill is reported but not diagnosed', () => { + // Nothing faulted, so there is no crash report to collect. + assert.deepEqual(classifyExit(false, null, 'SIGKILL'), { + kind: 'terminated', + signal: 'SIGKILL', + }); + assert.deepEqual(classifyExit(false, null, null), { kind: 'terminated', signal: null }); +}); + +test('a non-zero exit is the only case worth a crash report', () => { + assert.deepEqual(classifyExit(false, 1, null), { kind: 'failed', code: 1 }); + assert.deepEqual(classifyExit(false, 255, null), { kind: 'failed', code: 255 }); +}); diff --git a/launcher/test/textures.test.ts b/launcher/test/textures.test.ts index 9fe28c8..69538e1 100644 --- a/launcher/test/textures.test.ts +++ b/launcher/test/textures.test.ts @@ -52,11 +52,21 @@ test('lists the model texture variables with previews', async () => { const { root, project, entry } = await seed(); const textures = await listTextures(root, project, entry); - assert.equal(textures.length, 1); - assert.equal(textures[0].key, 'all'); - assert.equal(textures[0].reference, 'proj:block/lamp'); - assert.equal(textures[0].exists, true); - assert.equal(textures[0].width, 16); + // `all` and the particle slot. The generated model declares both because it is + // self-contained: inheriting from block/cube_all used to supply `particle: #all` for + // free, and dropping that parent — see shared/model-compat.ts — means saying it here or + // losing break and step particles. + assert.equal(textures.length, 2); + + const all = textures.find((texture) => texture.key === 'all'); + assert.ok(all, 'the drawable variable'); + assert.equal(all.reference, 'proj:block/lamp'); + assert.equal(all.exists, true); + assert.equal(all.width, 16); + + const particle = textures.find((texture) => texture.isParticleSlot); + assert.ok(particle, 'the particle slot'); + assert.equal(particle.reference, 'proj:block/lamp'); }); test('reports which faces use each variable', async () => { diff --git a/launcher/test/undo.test.ts b/launcher/test/undo.test.ts new file mode 100644 index 0000000..68a9090 --- /dev/null +++ b/launcher/test/undo.test.ts @@ -0,0 +1,354 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { UndoRegistry } from '../src/main/undo.ts'; +import { stashFiles, restoreStash, purgeStashes, TRASH_DIR } from '../src/main/trash.ts'; +import { + createProject, + createEntry, + deleteEntry, + restoreEntry, + loadProject, + removeModelParent, + writeModelFile, + textureRelativePath, + ProjectError, +} from '../src/main/project.ts'; +import { removeTexture, restoreTexture, addTexture } from '../src/main/textures.ts'; +import { setDataRoot } from '../src/main/paths.ts'; + +let workspace: string; + +test.before(async () => { + workspace = await mkdtemp(path.join(tmpdir(), 'ella-undo-')); + setDataRoot(workspace); +}); + +test.after(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +const exists = (target: string): Promise => + readFile(target).then(() => true, () => false); + +// --------------------------------------------------------------------------- +// The registry +// --------------------------------------------------------------------------- + +test('an offer runs the inverse it was registered with', async () => { + const registry = new UndoRegistry(); + let ran = false; + + const offer = registry.offer('entry.deletedDone', { id: 'x' }, async () => { + ran = true; + }); + + await registry.run(offer.token); + assert.equal(ran, true); +}); + +test('an offer carries what the notification needs to say', async () => { + const registry = new UndoRegistry(); + const offer = registry.offer('entry.deletedDone', { id: 'ruby' }, async () => {}); + + assert.equal(offer.messageKey, 'entry.deletedDone'); + assert.deepEqual(offer.values, { id: 'ruby' }); + assert.ok(offer.token.length > 0); +}); + +test('an offer is one-shot', async () => { + // Pressing Undo twice must not apply the inverse twice: restoring a deleted entry a + // second time would fail on the duplicate, and the second error is the confusing one. + const registry = new UndoRegistry(); + let runs = 0; + const offer = registry.offer('k', {}, async () => { + runs++; + }); + + await registry.run(offer.token); + await assert.rejects(registry.run(offer.token), (error: Error & { code: string }) => { + assert.equal(error.code, 'UNDO_EXPIRED'); + return true; + }); + assert.equal(runs, 1); +}); + +test('a failed inverse is not offered again', async () => { + // It has already done whatever part of its work it managed; running it again compounds + // that rather than retrying it. + const registry = new UndoRegistry(); + const offer = registry.offer('k', {}, async () => { + throw new Error('nope'); + }); + + await assert.rejects(registry.run(offer.token), /nope/); + await assert.rejects(registry.run(offer.token), /no longer be undone/); +}); + +test('two offers do not collide', async () => { + const registry = new UndoRegistry(); + const order: string[] = []; + + const first = registry.offer('a', {}, async () => void order.push('a')); + const second = registry.offer('b', {}, async () => void order.push('b')); + + await registry.run(second.token); + await registry.run(first.token); + assert.deepEqual(order, ['b', 'a']); +}); + +test('only the last few offers stay live', async () => { + // They hold closures over a project state that moves on; keeping them all would let an + // undo from ten actions ago apply to something it no longer describes. + const registry = new UndoRegistry(); + const offers = Array.from({ length: 12 }, (_, index) => + registry.offer(String(index), {}, async () => {}), + ); + + await assert.rejects(registry.run(offers[0].token), /no longer be undone/); + await registry.run(offers[11].token); +}); + +test('clearing drops every offer', async () => { + const registry = new UndoRegistry(); + const offer = registry.offer('k', {}, async () => {}); + + registry.clear(); + await assert.rejects(registry.run(offer.token), /no longer be undone/); +}); + +// --------------------------------------------------------------------------- +// The stash +// --------------------------------------------------------------------------- + +async function stashWorkspace(name: string): Promise { + const root = path.join(workspace, name); + await mkdir(path.join(root, 'pack', 'assets', 'x'), { recursive: true }); + await writeFile(path.join(root, 'pack', 'assets', 'x', 'a.json'), '{"a":1}', 'utf8'); + await writeFile(path.join(root, 'pack', 'assets', 'x', 'b.png'), 'png', 'utf8'); + return root; +} + +test('stashing moves files out of the project and back again', async () => { + const root = await stashWorkspace('stash-round-trip'); + const paths = ['pack/assets/x/a.json', 'pack/assets/x/b.png']; + + assert.deepEqual(await stashFiles(root, 'entry-a', paths), paths); + assert.equal(await exists(path.join(root, 'pack/assets/x/a.json')), false); + + assert.deepEqual((await restoreStash(root, 'entry-a')).sort(), paths); + assert.equal(await readFile(path.join(root, 'pack/assets/x/a.json'), 'utf8'), '{"a":1}'); +}); + +test('stashing skips a file that is not there', async () => { + // An entry may never have had a Blockbench source, and refusing to delete it over that + // would be absurd. + const root = await stashWorkspace('stash-missing'); + const moved = await stashFiles(root, 'entry-a', ['pack/assets/x/a.json', 'sources/a.bbmodel']); + + assert.deepEqual(moved, ['pack/assets/x/a.json']); +}); + +test('restoring never overwrites a file that came back on its own', async () => { + // Recreating an entry with the same id after deleting it is the case: the new file is + // the author's current work, and an undo of the old delete must not bury it. + const root = await stashWorkspace('stash-no-clobber'); + await stashFiles(root, 'entry-a', ['pack/assets/x/a.json']); + await writeFile(path.join(root, 'pack/assets/x/a.json'), 'newer', 'utf8'); + + assert.deepEqual(await restoreStash(root, 'entry-a'), []); + assert.equal(await readFile(path.join(root, 'pack/assets/x/a.json'), 'utf8'), 'newer'); +}); + +test('restoring an empty stash is not an error', async () => { + const root = await stashWorkspace('stash-empty'); + assert.deepEqual(await restoreStash(root, 'never-used'), []); +}); + +test('purging empties the trash', async () => { + const root = await stashWorkspace('stash-purge'); + await stashFiles(root, 'entry-a', ['pack/assets/x/a.json']); + + await purgeStashes(root); + assert.equal(await exists(path.join(root, TRASH_DIR, 'entry-a', 'pack/assets/x/a.json')), false); +}); + +// --------------------------------------------------------------------------- +// Undoing a delete +// --------------------------------------------------------------------------- + +async function projectWithEntries(namespace: string) { + const { project, root } = await createProject('U', namespace, '1.12.2'); + let current = project; + for (const id of ['one', 'two', 'three']) { + current = (await createEntry(root, current, { id, kind: 'block', displayName: { en: id } })) + .project; + } + return { project: current, root }; +} + +test('restoring a deleted entry puts it back where it was', async () => { + // Appending it would reorder a list the author arranged, for no reason other than + // convenience of implementation. + const { project, root } = await projectWithEntries('undodelete'); + const { project: after, entry, index } = await deleteEntry(root, project, 'two'); + + assert.equal(index, 1); + const { project: restored } = await restoreEntry(root, after, entry, index); + + assert.deepEqual(restored.entries.map((candidate) => candidate.id), ['one', 'two', 'three']); + assert.deepEqual((await loadProject(root)).entries.map((c) => c.id), ['one', 'two', 'three']); +}); + +test('restoring brings the files back when the delete took them', async () => { + const { project, root } = await projectWithEntries('undofiles'); + const entry = project.entries.find((candidate) => candidate.id === 'two')!; + const model = path.join(root, ...entry.model.output.split('/')); + const texture = path.join(root, ...textureRelativePath(project, entry).split('/')); + + const deleted = await deleteEntry(root, project, 'two', { deleteFiles: true }); + assert.equal(await exists(model), false); + assert.equal(await exists(texture), false, 'the texture goes too, as the dialog promises'); + + await restoreEntry(root, deleted.project, deleted.entry, deleted.index); + assert.equal(await exists(model), true); + assert.equal(await exists(texture), true); +}); + +test('a restored entry keeps its slot when it is still free', async () => { + const { project, root } = await projectWithEntries('undoslot'); + const deleted = await deleteEntry(root, project, 'two'); + const slot = deleted.entry.slot; + + const { entry } = await restoreEntry(root, deleted.project, deleted.entry, deleted.index); + assert.equal(entry.slot, slot); +}); + +test('a restored entry takes another slot rather than doubling up', async () => { + // Two entries on one slot is a live-editing bug that outlasts the session, and the slot + // is the one part of an entry that is disposable. + const { project, root } = await projectWithEntries('undoslottaken'); + const deleted = await deleteEntry(root, project, 'two'); + const freed = deleted.entry.slot as number; + + const { project: withNew } = await createEntry(root, deleted.project, { + id: 'squatter', + kind: 'block', + displayName: { en: 'Squatter' }, + slot: freed, + }); + + const { entry } = await restoreEntry(root, withNew, deleted.entry, deleted.index); + assert.notEqual(entry.slot, freed); + assert.equal(typeof entry.slot, 'number'); +}); + +test('restoring over an id that came back is refused', async () => { + const { project, root } = await projectWithEntries('undodup'); + const deleted = await deleteEntry(root, project, 'two'); + + const { project: recreated } = await createEntry(root, deleted.project, { + id: 'two', + kind: 'block', + displayName: { en: 'Two again' }, + }); + + await assert.rejects( + restoreEntry(root, recreated, deleted.entry, deleted.index), + (error: ProjectError) => { + assert.equal(error.code, 'DUPLICATE_ID'); + return true; + }, + ); +}); + +// --------------------------------------------------------------------------- +// Undoing a texture removal +// --------------------------------------------------------------------------- + +test('restoring a texture variable puts the key back', async () => { + const { project, root } = await createProject('T', 'undotex', '1.12.2'); + const { project: withEntry, entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + await addTexture(root, withEntry, entry, 'side'); + + const { removed, textures } = await removeTexture(root, withEntry, entry, 'side'); + assert.equal(textures.some((texture) => texture.key === 'side'), false); + + const after = await restoreTexture(root, withEntry, entry, removed); + const side = after.find((texture) => texture.key === 'side'); + assert.equal(side?.reference, removed.reference); +}); + +test('restoring a texture puts the particle entry back with it', async () => { + // Removing a variable the particle pointed at clears both; an undo that restored only + // one would leave the model half-reverted. + const { project, root } = await createProject('T', 'undopart', '1.12.2'); + const { project: withEntry, entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + const { removed } = await removeTexture(root, withEntry, entry, 'all'); + assert.equal(removed.wasParticle, true, 'the generated model points particle at #all'); + + const after = await restoreTexture(root, withEntry, entry, removed); + const particle = after.find((texture) => texture.key === 'particle'); + assert.equal(particle?.reference, removed.reference); +}); + +test('a texture whose file was deleted is not restored as a dangling reference', async () => { + // A variable pointing at a missing file stops the whole model loading — worse than the + // removal it was meant to undo. + const { project, root } = await createProject('T', 'undogone', '1.12.2'); + const { project: withEntry, entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + const { removed } = await removeTexture(root, withEntry, entry, 'all', { deleteFile: true }); + + await assert.rejects( + restoreTexture(root, withEntry, entry, removed), + (error: ProjectError) => { + assert.equal(error.code, 'TEXTURE_FILE_GONE'); + return true; + }, + ); +}); + +// --------------------------------------------------------------------------- +// Undoing a model rewrite +// --------------------------------------------------------------------------- + +test('removing a parent hands back the file as it was', async () => { + const { project, root } = await createProject('M', 'undoparent', '1.12.2'); + const { entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + const file = path.join(root, ...entry.model.output.split('/')); + const authored = '{\n "parent": "block/cube_all",\n "elements": [ ]\n}\n'; + await writeFile(file, '{"parent":"block/cube_all","elements":[{"from":[0,0,0]}]}', 'utf8'); + const before = await readFile(file, 'utf8'); + + const removed = await removeModelParent(root, entry); + assert.equal(removed?.parent, 'block/cube_all'); + assert.equal(removed?.original, before, 'byte-for-byte, not re-derived'); + assert.equal((await readFile(file, 'utf8')).includes('parent'), false); + + // Undoing is writing that text back, which also restores whatever formatting it had. + await writeModelFile(root, entry, removed!.original); + assert.equal(await readFile(file, 'utf8'), before); + assert.notEqual(before, authored); +}); + +test('a model with nothing to fix reports nothing to undo', async () => { + const { project, root } = await createProject('M', 'undonoop', '1.12.2'); + const { entry } = await createEntry(root, project, { + id: 'lamp', kind: 'block', displayName: { en: 'Lamp' }, + }); + + assert.equal(await removeModelParent(root, entry), null); +}); diff --git a/launcher/test/version-compat.test.ts b/launcher/test/version-compat.test.ts new file mode 100644 index 0000000..74b27bf --- /dev/null +++ b/launcher/test/version-compat.test.ts @@ -0,0 +1,334 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + inspectModel, + migrateModel, + retargetVanillaTexture, + vanillaTextureFolders, + versionChangeNotes, + isVersionChange, + type VersionFacts, +} from '../src/shared/version-compat.ts'; +import { planVersionChange, applyVersionChange } from '../src/main/version-change.ts'; +import { createProject, createEntry, loadProject } from '../src/main/project.ts'; +import { setDataRoot } from '../src/main/paths.ts'; + +let workspace: string; + +test.before(async () => { + workspace = await mkdtemp(path.join(tmpdir(), 'ella-compat-')); + setDataRoot(workspace); +}); + +test.after(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Vanilla texture folders +// --------------------------------------------------------------------------- + +test('vanilla texture folders follow the 1.13 rename', () => { + assert.deepEqual(vanillaTextureFolders('1.12.2'), { block: 'blocks', item: 'items' }); + assert.deepEqual(vanillaTextureFolders('1.13'), { block: 'block', item: 'item' }); + assert.deepEqual(vanillaTextureFolders('1.21.1'), { block: 'block', item: 'item' }); +}); + +test('a modded version id is read as the release it is built on', () => { + // `1.12.2-forge-14.23.5.2859` does not parse as a version, and an unparseable id sorts + // after every release — which would answer "yes" to "is this at least 1.13?". + assert.deepEqual(vanillaTextureFolders('1.12.2-forge-14.23.5.2859'), { + block: 'blocks', + item: 'items', + }); +}); + +test('retargets a vanilla texture across the rename, in both directions', () => { + assert.equal(retargetVanillaTexture('minecraft:blocks/stone', '1.21.1'), 'minecraft:block/stone'); + assert.equal(retargetVanillaTexture('minecraft:block/stone', '1.12.2'), 'minecraft:blocks/stone'); + assert.equal(retargetVanillaTexture('minecraft:items/apple', '1.21.1'), 'minecraft:item/apple'); + assert.equal(retargetVanillaTexture('minecraft:item/apple', '1.12.2'), 'minecraft:items/apple'); +}); + +test('an unqualified reference is vanilla, and keeps its spelling', () => { + // Minecraft resolves a bare path against `minecraft`, so this is the same mistake — and + // adding a namespace the author did not write would be a second, unrelated change. + assert.equal(retargetVanillaTexture('blocks/stone', '1.21.1'), 'block/stone'); +}); + +test('leaves a reference that already suits the version alone', () => { + assert.equal(retargetVanillaTexture('minecraft:block/stone', '1.21.1'), null); + assert.equal(retargetVanillaTexture('minecraft:blocks/stone', '1.12.2'), null); +}); + +test("never touches a pack's own textures", () => { + // A resource pack may lay its own namespace out however it likes: the reference *is* the + // path. Rewriting these would break the one spelling that works on every version. + assert.equal(retargetVanillaTexture('myproject:block/ruby', '1.12.2'), null); + assert.equal(retargetVanillaTexture('myproject:blocks/ruby', '1.21.1'), null); +}); + +test('leaves texture variables alone', () => { + assert.equal(retargetVanillaTexture('#all', '1.12.2'), null); +}); + +test('ignores vanilla paths outside the renamed folders', () => { + assert.equal(retargetVanillaTexture('minecraft:entity/creeper/creeper', '1.12.2'), null); + assert.equal(retargetVanillaTexture('minecraft:stone', '1.21.1'), null); +}); + +// --------------------------------------------------------------------------- +// Inspection +// --------------------------------------------------------------------------- + +const CUBE_WITH_PARENT = { + parent: 'block/cube_all', + textures: { all: 'myproject:block/ruby' }, + elements: [{ from: [0, 0, 0], to: [16, 16, 16], faces: {} }], +}; + +test('a parent over own geometry is only a problem below 1.9', () => { + assert.deepEqual( + inspectModel(CUBE_WITH_PARENT, '1.8.9').map((issue) => issue.id), + ['parentOverridesGeometry'], + ); + assert.deepEqual(inspectModel(CUBE_WITH_PARENT, '1.12.2'), []); +}); + +test('a parent with no geometry of its own is never a problem', () => { + // The ordinary `cube_all` case, and Ella's own slot redirects. + const model = { parent: 'block/cube_all', textures: { all: 'x' } }; + assert.deepEqual(inspectModel(model, '1.8.9'), []); +}); + +test('reports a vanilla texture written for the other era', () => { + const model = { textures: { all: 'minecraft:blocks/stone' } }; + const [issue] = inspectModel(model, '1.21.1'); + + assert.equal(issue.id, 'vanillaTextureFolder'); + assert.equal(issue.fixable, true); + assert.equal(issue.detail.reference, 'minecraft:blocks/stone'); + assert.equal(issue.detail.expected, 'minecraft:block/stone'); +}); + +test('several stale references in one model are one finding', () => { + // They are the same mistake made once and fixed in one pass; a row each would bury the + // rest of the report. + const model = { + textures: { a: 'minecraft:blocks/stone', b: 'minecraft:blocks/dirt', c: '#a' }, + }; + const issues = inspectModel(model, '1.21.1'); + + assert.equal(issues.length, 1); + assert.equal(issues[0].detail.count, 2); +}); + +test('a model that is right for the version has nothing to report', () => { + const model = { textures: { all: 'myproject:block/ruby' }, elements: [] }; + assert.deepEqual(inspectModel(model, '1.8.9'), []); + assert.deepEqual(inspectModel(model, '1.21.1'), []); +}); + +test('rejects things that are not models rather than throwing', () => { + assert.deepEqual(inspectModel(null, '1.12.2'), []); + assert.deepEqual(inspectModel('nope', '1.12.2'), []); +}); + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +test('migrating to 1.8 drops the parent that would override the geometry', () => { + const { model, applied } = migrateModel(CUBE_WITH_PARENT, '1.8.9'); + + assert.deepEqual(applied, ['parentOverridesGeometry']); + assert.equal('parent' in model, false); + assert.deepEqual(model.elements, CUBE_WITH_PARENT.elements, 'the geometry survives'); +}); + +test('migrating rewrites vanilla texture references', () => { + const source = { textures: { all: 'minecraft:blocks/stone', own: 'myproject:block/ruby' } }; + const { model } = migrateModel(source, '1.21.1'); + + assert.deepEqual(model.textures, { + all: 'minecraft:block/stone', + own: 'myproject:block/ruby', + }); +}); + +test('migration leaves the input untouched', () => { + const source = { parent: 'block/cube_all', elements: [{ from: [0, 0, 0], to: [1, 1, 1] }] }; + migrateModel(source, '1.8.9'); + assert.equal(source.parent, 'block/cube_all'); +}); + +test('migrating twice changes nothing the second time', () => { + // The dialog can be answered more than once over a project's life; a migration that + // drifted on each pass would rewrite files for no reason and dirty every diff. + const first = migrateModel({ textures: { all: 'minecraft:blocks/stone' } }, '1.21.1'); + const second = migrateModel(first.model, '1.21.1'); + + assert.deepEqual(second.applied, []); + assert.deepEqual(second.model, first.model); +}); + +test('a model can need both fixes at once', () => { + const source = { + parent: 'block/cube_all', + textures: { all: 'minecraft:block/stone' }, + elements: [{ from: [0, 0, 0], to: [16, 16, 16] }], + }; + const { applied } = migrateModel(source, '1.8.9'); + + assert.deepEqual(applied.sort(), ['parentOverridesGeometry', 'vanillaTextureFolder']); +}); + +// --------------------------------------------------------------------------- +// Consequences beyond the files +// --------------------------------------------------------------------------- + +const facts = (over: Partial): VersionFacts => ({ + id: '1.12.2', + installed: true, + adapterStatus: 'built', + javaAvailable: true, + requiredJava: 8, + ...over, +}); + +test('a version with no adapter is called out', () => { + const notes = versionChangeNotes(null, facts({ id: '1.19.2', adapterStatus: null })); + assert.deepEqual(notes.map((note) => note.id), ['noAdapter']); +}); + +test('losing live editing is its own note', () => { + // Told apart from "this version cannot live-edit" on purpose: it matters far more to + // someone who had it a moment ago. + const notes = versionChangeNotes( + facts({ id: '1.12.2' }), + facts({ id: '1.19.2', adapterStatus: null }), + ); + assert.deepEqual(notes.map((note) => note.id), ['noAdapter', 'losesLiveEditing']); +}); + +test('moving between two live-editing versions says nothing', () => { + assert.deepEqual(versionChangeNotes(facts({}), facts({ id: '1.21.1' })), []); +}); + +test('a missing Java runtime is reported with the version it is for', () => { + const notes = versionChangeNotes(null, facts({ id: '1.21.1', javaAvailable: false, requiredJava: 21 })); + assert.deepEqual(notes.map((note) => note.id), ['javaMissing']); + assert.equal(notes[0].detail.java, 21); +}); + +test('an unbound project is not a version change', () => { + assert.equal(isVersionChange(null, '1.12.2'), false); + assert.equal(isVersionChange('1.12.2', '1.12.2'), false); + assert.equal(isVersionChange('1.12.2', '1.8.9'), true); +}); + +// --------------------------------------------------------------------------- +// Against a project on disk +// --------------------------------------------------------------------------- + +async function projectWithModel(namespace: string, model: unknown) { + const { project, root } = await createProject('P', namespace, '1.12.2'); + const created = await createEntry(root, project, { + id: 'ruby', + kind: 'block', + displayName: { en: 'Ruby' }, + }); + + const file = path.join(root, ...created.entry.model.output.split('/')); + await writeFile(file, JSON.stringify(model, null, 2), 'utf8'); + + return { project: created.project, root, file }; +} + +test('planning reads the models as they are on disk', async () => { + const { project, root } = await projectWithModel('planned', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + const plan = await planVersionChange(root, project, '1.21.1'); + + assert.equal(plan.from, '1.12.2'); + assert.equal(plan.needsConfirmation, true); + assert.equal(plan.fixable, 1); + assert.deepEqual(plan.findings.map((finding) => finding.entryId), ['ruby']); +}); + +test('planning a launch on the project\'s own version asks for nothing', async () => { + const { project, root } = await projectWithModel('samever', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + const plan = await planVersionChange(root, project, '1.12.2'); + assert.equal(plan.needsConfirmation, false); + assert.deepEqual(plan.findings, []); +}); + +test('a missing model file is not reported as a version problem', async () => { + // validateForExport already reports it properly; a second diagnosis for the same file + // would only compete with the first. + const { project, root, file } = await projectWithModel('nomodel', { textures: {} }); + await rm(file); + + const plan = await planVersionChange(root, project, '1.21.1'); + assert.deepEqual(plan.findings, []); +}); + +test('applying without migrating rebinds the project and leaves the files alone', async () => { + const { project, root, file } = await projectWithModel('nomigrate', { + textures: { all: 'minecraft:blocks/stone' }, + }); + const before = await readFile(file, 'utf8'); + + const result = await applyVersionChange(root, project, '1.21.1', { migrate: false }); + + assert.deepEqual(result.migrated, []); + assert.equal(result.project.targetVersion, '1.21.1'); + assert.equal((await loadProject(root)).targetVersion, '1.21.1'); + assert.equal(await readFile(file, 'utf8'), before); +}); + +test('applying with migration rewrites the models and rebinds', async () => { + const { project, root, file } = await projectWithModel('migrate', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + const result = await applyVersionChange(root, project, '1.21.1', { migrate: true }); + + assert.deepEqual(result.migrated, ['ruby']); + const written = JSON.parse(await readFile(file, 'utf8')) as { textures: Record }; + assert.equal(written.textures.all, 'minecraft:block/stone'); + + // And the project is now clean for the version it is bound to. + const plan = await planVersionChange(root, result.project, '1.21.1'); + assert.deepEqual(plan.findings, []); +}); + +test('a migrated model keeps the formatting Blockbench writes', async () => { + // These are the author's files; the next save has to see something it recognises rather + // than a diff of the whole document. + const { project, root, file } = await projectWithModel('formatting', { + textures: { all: 'minecraft:blocks/stone' }, + }); + + await applyVersionChange(root, project, '1.21.1', { migrate: true }); + const written = await readFile(file, 'utf8'); + + assert.match(written, /^\{\n {2}"textures"/, 'two-space indentation'); + assert.match(written, /\n$/, 'trailing newline'); +}); + +test('a project bound to nothing adopts the version it is applied to', async () => { + const { project, root } = await createProject('Fresh', 'freshproj'); + assert.equal(project.targetVersion, null); + + const result = await applyVersionChange(root, project, '1.12.2', { migrate: false }); + assert.equal(result.project.targetVersion, '1.12.2'); +}); diff --git a/launcher/test/version.test.ts b/launcher/test/version.test.ts index 7ddaec3..58ac227 100644 --- a/launcher/test/version.test.ts +++ b/launcher/test/version.test.ts @@ -87,15 +87,27 @@ test('picks the Java runtime each version actually needs', () => { }); test('routes a version only to an adapter that is actually built', () => { + assert.equal(adapterFor('1.8.9'), 'forge-1.8.9'); assert.equal(adapterFor('1.12.2'), 'forge-1.12.2'); assert.equal(adapterFor('1.21.1'), 'forge-modern'); // Designed-for but unbuilt buckets must not be offered as working. - assert.equal(adapterFor('1.8.9'), null); assert.equal(adapterFor('1.16.5'), null); assert.equal(adapterFor('1.20.1'), null); }); +test('the 1.8 adapter claims only the two versions its mappings cover', () => { + // 1.8.8 and 1.8.9 share every obfuscated name the adapter overrides, so one jar serves + // both. 1.8 through 1.8.7 do not, and offering the jar there would bind the overrides + // to whatever those names happen to mean instead — a mod that loads and misbehaves, + // which is worse than one that is simply unavailable. + assert.equal(adapterFor('1.8.8'), 'forge-1.8.9'); + assert.equal(adapterFor('1.8.9'), 'forge-1.8.9'); + assert.equal(adapterFor('1.8'), null); + assert.equal(adapterFor('1.8.7'), null); + assert.equal(adapterFor('1.9'), null); +}); + test('a planned bucket is still reported as covering its range', () => { // The UI distinguishes "no adapter will ever apply" from "not built yet". assert.equal(adapterCoverageFor('1.16.5')?.id, 'forge-mid'); diff --git a/launcher/test/workflow.test.ts b/launcher/test/workflow.test.ts new file mode 100644 index 0000000..b929a3b --- /dev/null +++ b/launcher/test/workflow.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + workflowSteps, + isSetupComplete, + completedCount, + currentStep, + canLiveEdit, + type WorkflowFacts, +} from '../src/shared/workflow.ts'; +import { translate, LOCALES } from '../src/shared/i18n.ts'; + +const NOTHING: WorkflowFacts = { + installedVersions: 0, + liveEditingVersions: 0, + hasProject: false, + entryCount: 0, + blockbenchFound: false, + connected: false, +}; + +const EVERYTHING: WorkflowFacts = { + installedVersions: 2, + liveEditingVersions: 1, + hasProject: true, + entryCount: 3, + blockbenchFound: true, + connected: true, +}; + +test('a fresh install has nothing done and starts at the first step', () => { + const steps = workflowSteps(NOTHING); + assert.equal(completedCount(steps), 0); + assert.equal(currentStep(steps)?.id, 'version'); + assert.equal(isSetupComplete(steps), false); +}); + +test('a fully set up session has no current step', () => { + const steps = workflowSteps(EVERYTHING); + assert.equal(isSetupComplete(steps), true); + assert.equal(currentStep(steps), null); +}); + +test('exactly one step is current, however many are outstanding', () => { + const steps = workflowSteps({ ...NOTHING, installedVersions: 1, blockbenchFound: true }); + assert.equal(steps.filter((step) => step.current).length, 1); + // Blockbench being found already does not pull it in front of the project step. + assert.equal(currentStep(steps)?.id, 'project'); +}); + +test('a later step can be done while an earlier one is not', () => { + const steps = workflowSteps({ ...NOTHING, blockbenchFound: true }); + const blockbench = steps.find((step) => step.id === 'blockbench'); + assert.equal(blockbench?.done, true); + assert.equal(blockbench?.current, false); +}); + +test('closing the project un-does the steps that depended on it', () => { + // Entry count is stale for a moment after a project closes; the step must not claim + // an entry exists when there is no project to hold it. + const steps = workflowSteps({ ...EVERYTHING, hasProject: false }); + assert.equal(steps.find((step) => step.id === 'entry')?.done, false); +}); + +test('the game disconnecting reopens the launch step', () => { + const steps = workflowSteps({ ...EVERYTHING, connected: false }); + assert.equal(isSetupComplete(steps), false); + assert.equal(currentStep(steps)?.id, 'launch'); +}); + +test('installed versions without a built adapter cannot live-edit', () => { + assert.equal(canLiveEdit({ ...NOTHING, installedVersions: 3 }), false); + assert.equal(canLiveEdit({ ...NOTHING, installedVersions: 3, liveEditingVersions: 1 }), true); +}); + +test('every step has a title, a reason and an action in both locales', () => { + for (const step of workflowSteps(NOTHING)) { + for (const locale of LOCALES) { + for (const suffix of ['title', 'why', 'action']) { + const key = `guide.${step.id}.${suffix}`; + assert.notEqual(translate(locale, key), key, `${key} is missing in ${locale}`); + } + } + } +}); diff --git a/launcher/tsconfig.node.tsbuildinfo b/launcher/tsconfig.node.tsbuildinfo deleted file mode 100644 index 5ae20be..0000000 --- a/launcher/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./src/shared/version.ts","./src/main/paths.ts","./src/main/adapters.ts","./src/main/blockbench-plugin.ts","./src/shared/protocol.ts","./locales/en.json","./locales/fr.json","./src/shared/i18n.ts","./src/main/config.ts","./src/main/blockbench.ts","./src/main/minecraft/download.ts","./src/main/java-runtime.ts","./src/main/minecraft/forge.ts","./src/main/minecraft/types.ts","./src/main/minecraft/rules.ts","./src/main/minecraft/libraries.ts","./src/main/minecraft/manifest.ts","./src/main/minecraft/launch.ts","./src/main/diagnostics.ts","./src/main/ella-server.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/adm-zip/util.d.ts","./node_modules/@types/adm-zip/index.d.ts","./src/shared/project.ts","./src/main/pack.ts","./src/main/export.ts","./node_modules/electron/electron.d.ts","./src/shared/ipc.ts","./src/main/minecraft/install.ts","./src/main/minecraft/uninstall.ts","./src/shared/settings-schema.ts","./src/main/project.ts","./src/main/session.ts","./src/main/textures.ts","./src/main/index.ts","./src/preload/index.ts","./src/shared/model-preview.ts","./test/diagnostics.test.ts","./test/ella-server.test.ts","./test/export.test.ts","./test/i18n.test.ts","./test/java-runtime.test.ts","./test/minecraft.test.ts","./test/model-preview.test.ts","./test/pack-project.test.ts","./test/project-delete.test.ts","./test/rename.test.ts","./test/settings-schema.test.ts","./test/texture.test.ts","./test/textures.test.ts","./test/uninstall.test.ts","./test/version.test.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/electron-vite/dist/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./electron.vite.config.ts"],"fileIdsList":[[87,137,154,155,159,251,258],[87,137,154,155],[87,137,154,155,252],[87,137,149,154,155,187,188],[87,137,154,155,252,253,254,255,256],[87,137,154,155,252,254],[87,134,135,137,154,155],[87,136,137,154,155],[137,154,155],[87,137,142,154,155,172],[87,137,138,143,148,154,155,157,169,180],[87,137,138,139,148,154,155,157],[82,83,84,87,137,154,155],[87,137,140,154,155,181],[87,137,141,142,149,154,155,158],[87,137,142,154,155,169,177],[87,137,143,145,148,154,155,157],[87,136,137,144,154,155],[87,137,145,146,154,155],[87,137,147,148,154,155],[87,136,137,148,154,155],[87,137,148,149,150,154,155,169,180],[87,137,148,149,150,154,155,164,169,172],[87,129,137,145,148,151,154,155,157,169,180],[87,137,148,149,151,152,154,155,157,169,177,180],[87,137,151,153,154,155,169,177,180],[85,86,87,88,89,90,91,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186],[87,137,148,154,155],[87,137,154,155,156,180],[87,137,145,148,154,155,157,169],[87,137,154,155,158],[87,137,154,155,159],[87,136,137,154,155,160],[87,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186],[87,137,154,155,162],[87,137,154,155,163],[87,137,148,154,155,164,165],[87,137,154,155,164,166,181,183],[87,137,149,154,155],[87,137,148,154,155,169,170,172],[87,137,154,155,171,172],[87,137,154,155,169,170],[87,137,154,155,172],[87,137,154,155,173],[87,134,137,154,155,169,174,180],[87,137,148,154,155,175,176],[87,137,154,155,175,176],[87,137,142,154,155,157,169,177],[87,137,154,155,178],[87,137,154,155,157,179],[87,137,151,154,155,163,180],[87,137,142,154,155,181],[87,137,154,155,169,182],[87,137,154,155,156,183],[87,137,154,155,184],[87,129,137,154,155],[87,129,137,148,150,154,155,160,169,172,180,182,183,185],[87,137,154,155,169,186],[87,137,154,155,250,257],[87,137,154,155,187,250],[87,137,148,149,154,155,187],[87,137,154,155,242],[87,137,154,155,240,242],[87,137,154,155,231,239,240,241,243,245],[87,137,154,155,229],[87,137,154,155,232,237,242,245],[87,137,154,155,228,245],[87,137,154,155,232,233,236,237,238,245],[87,137,154,155,232,233,234,236,237,245],[87,137,154,155,229,230,231,232,233,237,238,239,241,242,243,245],[87,137,154,155,245],[87,137,154,155,227,229,230,231,232,233,234,236,237,238,239,240,241,242,243,244],[87,137,154,155,227,245],[87,137,154,155,232,234,235,237,238,245],[87,137,154,155,236,245],[87,137,154,155,237,238,242,245],[87,137,154,155,230,240],[87,137,154,155,220,249],[87,137,154,155,219,220],[87,101,105,137,154,155,180],[87,101,137,154,155,169,180],[87,96,137,154,155],[87,98,101,137,154,155,177,180],[87,137,154,155,157,177],[87,137,154,155,187],[87,96,137,154,155,187],[87,98,101,137,154,155,157,180],[87,93,94,97,100,137,148,154,155,169,180],[87,101,108,137,154,155],[87,93,99,137,154,155],[87,101,122,123,137,154,155],[87,97,101,137,154,155,172,180,187],[87,122,137,154,155,187],[87,95,96,137,154,155,187],[87,101,137,154,155],[87,95,96,97,98,99,100,101,102,103,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,123,124,125,126,127,128,137,154,155],[87,101,116,137,154,155],[87,101,108,109,137,154,155],[87,99,101,109,110,137,154,155],[87,100,137,154,155],[87,93,96,101,137,154,155],[87,101,105,109,110,137,154,155],[87,105,137,154,155],[87,99,101,104,137,154,155,180],[87,93,98,101,108,137,154,155],[87,137,154,155,169],[87,96,101,122,137,154,155,185,187],[87,137,148,149,151,152,153,154,155,157,169,177,180,186,187,220,221,222,223,224,225,226,246,247,248,249],[87,137,154,155,222,223,224,225],[87,137,154,155,222,223,224],[87,137,154,155,222],[87,137,154,155,223],[87,137,154,155,220],[62,63,87,137,142,150,154,155,159,180],[87,137,150,154,155,158,159,180],[70,87,137,138,148,149,150,154,155,159],[63,66,69,87,137,149,150,154,155,158,159,181],[62,63,73,74,79,87,137,150,154,155,158,159],[66,87,137,142,148,154,155,157],[87,137,150,154,155,159,189,190,191],[62,63,64,65,70,73,74,78,80,87,137,154,155,159,180,191,192,193,194,195,196,198,199,200],[62,87,137,138,149,150,154,155,158,159,181],[87,137,142,149,150,154,155,159,169,170],[62,63,72,73,87,137,138,150,154,155,159],[63,72,75,77,78,87,137,150,154,155,159,189],[63,66,73,75,76,77,78,87,137,138,142,154,155,159],[75,76,87,137,154,155,159],[62,63,72,75,77,87,137,150,154,155,159],[75,87,137,154,155,158],[63,74,87,137,150,154,155,159],[66,87,137,150,154,155,159,186,190],[87,137,150,154,155,158,159],[63,66,87,137,142,150,154,155,159,190,191,197],[64,66,70,71,74,79,80,81,87,137,138,142,148,154,155,159,190,191,198],[87,137,150,154,155,159,190,191,198],[87,137,154,155,193,194],[67,68,87,137,154,155],[66,69,87,137,154,155,190],[66,87,137,154,155],[63,80,87,135,137,150,154,155,158,159,174],[66,81,87,135,137,148,154,155,157,174],[87,135,137,150,154,155,158,159,174,189,190,192,197],[69,87,135,137,154,155,174,197],[73,87,135,137,154,155,174],[75,76,77,79,87,135,137,154,155,159,174],[87,135,137,154,155,174,203],[62,63,87,135,137,150,154,155,158,159,174,186,190,191,198],[63,87,135,137,150,154,155,158,159,174,198],[63,87,135,137,150,154,155,158,159,174,190,198],[66,87,135,137,154,155,174,197],[63,87,135,137,150,154,155,158,159,174,191,198],[63,87,135,137,150,154,155,158,159,174,190,191,198,200],[63,64,87,135,137,150,154,155,158,159,174,196],[62,87,135,137,154,155,174]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d738b4fe64ce86e7f2b349b816af4c33bc1135c684e044da17a0939a4236a11","signature":"09dec37ae00390b067901a933cfdd2f4311c1f55f4d496fa77b77faee43a09eb"},"fce5ccc4e0e8435649dba2e937c899cbd7d016eb3db0ce8c93f9be664bd6c757","7d2890b169a0b70e6088a7450c89e266e6c750252a33e500d2faefa75a6d047f",{"version":"9f3ce99e3ffebfd8d89d147fa22efe1fb5f6130e458ef5c9143cce74027806a5","signature":"05c93ad6c450c594ee711e5bbcfc65e84173af5a202db32c5e732de36068f002"},{"version":"7f93ec79df97ce06a9f7e2d87091f4ff14f7fb6a882375abbca1e488bae5c2c0","signature":"c32dc91adfdb43dc74adf80238b1e73ff47a3cebe4cff0631b18953a37a9a684"},{"version":"0e25dd83bfa28c7f5fb9a961505623c1d17bc35569b087878471f83e5556e3e0","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},{"version":"980796b30a30050db7b81611ecff08804c466058d15d2bef1134b1e1422d2ef8","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},"2fcc52ac9003375e35b45f3c4e05912549ccf7a94bdf28ebf81fb9658afcf376","05fd9f833fd1f09d57518ea1e141c9eea930228a13d17fbf1273a638b971ee6b","e8e073b22d9b564c575fcf9adc1c7df24d920dd19e8dfa598ed44ff2fec08f7b",{"version":"eea92a9a8245e5850df12ac2ea50e89e8b7db1f55725687d1f633f5713439764","signature":"9136c5188e48b4a50ce996953dcf08a38ed782159781a8184b8bca66e3e2a639"},"cb7766b043983f7cb1c942334aaa353058ef63b543de9afa02fe57059731edf1","3d1f235b49c982dcff2ed742bfb2711cee6feb085248f9e370822ecf3471fb22",{"version":"1e7852d5ae1ef5fd555e057c9d3a2992b6f7985f8ae432f172aff80eba6ab4ae","signature":"2d5e88f34e4648099405c6d86ed53127f04151a42cd3bfe1ff515a7facab89a8"},"237373264d871eac5c49281fc0b53eee1c4f2380a23957d9df3b065f353c284b",{"version":"55a29d4ae913aaad889b7dc6dd9cc62620e69a08bbc9ff78cfe3c984163dc878","signature":"d3b76565851c3b9cac396f7a068de96ec24822cb6c1db2c96a1428aafa1d02a4"},"28414d91c0df47a8a7115fd2835762359b64490631b2411f2f38f9c41500e151","ccfbc05766ec18fa552cfbc6ec797e5b02d8469928f6e0ad6c8c917077b9c102","a286879fd6abc9aefda7464c4d04b2ee32b632c641ffef29ee759f40c1c739b8","71b7f88c1df971b8e45c0193186d53621c7cf45839ba886b626519e1d1cc70e1",{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d97fb21da858fb18b8ae72c314e9743fd52f73ebe2764e12af1db32fc03f853f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"78dbea00e90d2df8ea3dbef0cc379d95b8be9b71cd6bde4c28728f306811803b","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e1e46d0a9837ee058c100501080c920fa98081ea3956af0374308ba6f22a33e","impliedFormat":1},{"version":"272ca407e0c9068bdc5152552d876e68037ceae3de62e529306403e973dec8e1","impliedFormat":1},{"version":"fa7834c715d5357e4540cee40ce96c3250ddb67a7b879a6b7fa0e86d6696f121","impliedFormat":1},{"version":"22dfb07a7ab15b66ac043829056fe70124844636ae719551812ac631ba04985b","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"0cb167c371eaa8c869f8a7656a7296f2e4fae43b4d8b803a680236b24794e5f9","impliedFormat":1},{"version":"0a839dba0287cc0481ad4beedd48a1c64acf1e212ae865d1315f7007ca215161","impliedFormat":1},{"version":"38dc4655376cd1a4bd6bb3763d92949233e33d38d3dd3cbea7bbf218175a38ef","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"d61e0a64cd175208ac0b83670151a9a6b5916f0d1ffcdc5c29c90b1cebfc5045","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"48a679952eefe4cb776d5a0e1ccba2d3eb53b57448bbb7abc1fcebcbd5440188","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2d14da6ecb49bf828d83948765ec2d3a579d476bbb9645e749610baa6ec880ca","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"0aef708fb4c7a6b915e8305cbfac40cd207b032dbaabe9a01889a5fff3254681","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"ad9bdafb4e7abf14cc53ce7970486a84c87831e62891e5dfe798ddcd55e84701","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"71d3ae6a5e73ca4130762560425e00984ebaff64d5353a3333d1bb7eb86ef336","impliedFormat":1},{"version":"9063bd95bf4fe06fe71b4f1abd4dce7d41b684d9036ff4afae6e079eb252b19e","impliedFormat":1},{"version":"6fea400d16717040fe04edaf9833d7fe4680755a1e7ed03c87e961c5cde3b8a0","impliedFormat":1},{"version":"0230fd5c0a569a1ab873df98051bac5e163e06adb8669f8713e794afd48f0e02","signature":"a1b601110bbab5f5def3fd39dde2ec65b272490321549295db19d2e3e6652857"},"35c36b8fe740993b80cee7d5be460ec7b8471d94dedaf58bdbd50390d8337221","87a547302ce6719e13cb0a37c248133ba428d98a33192bd849b1d78cf65deb7b",{"version":"e559d0282980f0f8f2e400dc668514cdb2b0f2dac62f427dbc4df3272eb811fa","affectsGlobalScope":true,"impliedFormat":1},"cdf0d035d910b844495d5dc3464d7cbb65031bc2d70d6b3627be67789fe50901","ab37ada03cac1fab06150b9210d661d902ea540ac84ad1d3070f08001177470f","85a306b977025686111277684835f35148062bd4fb9cb3fe7d7105a80ec2cc39",{"version":"653d38e051e88413fb0adb044643723bf5b73ac17df0edc913bce8516c5b5772","signature":"e293561dcd96b3dffbe7db0d0da7d7185359ddc5070b5d3de58322c683402172"},{"version":"37f62bc9b775750b07d284ce465d5f5ac6ff88ae89d898889381b0115cfa9c85","signature":"2ccf95d83be7d742eb5e503327cf3c010a55673ee437fe39bc0302f25f9c9a29"},"d94ceb32a4f94c1f0b9c8d675960ad16d64959f205816609d813fb63cbfe346a","028781afd602cc9ad8f2bf6c776f47cce0d18164493f741b16949e5cca8dcd25","2a3677f806849c3c1ce947083c40eb94c0617a7651826f34a8d186c46effea70","05a65b70593f086594af8b11b647d4a47084c918710bc087395d449d5fb747bd",{"version":"91e985d8f993207fa3192dbd8fbff4d80065d9cfd2a1dde31b096d8dbb8871c8","signature":"3329a954eb09aaaa16c0e90bc78f8093be20803ecef7be9babf85adb79f4cd66"},"f32275d551e34646e8864efc151d6d1e65ba3ce08f64ab4c8cfb0f9535f815ec","d3b10140feb2fdb03418e97771bf2be3858905aa11c297daf58ae49e1a6ded12","12c8351d85c4197b3a4b91cecc17690e536869ecc550f59195c91726a7b15c60","f4f82fc2f095c4b964001976ec1134c956aac315714327e805e2cc84263b5169","a9ed2e7f363f9848017cc8c3276e60afb2ad5adb659d891631b2499e9a4c1cc3","78a917bb0baa43aa0a4406b6609041622a303cab58530737e409123e41c241d4",{"version":"96d34cd39ea1e022a837a30a068b89e002b1c7756b8de40fae8253a8d1953549","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},"008d11592c99e5f8bdf956495be91d6c45012a4624684fe3e5dbe12a1c3459dd","b126b3146876e84bbf5d18189c88f0413627b60d92cd7c4a6ca69250c20ee24a","f8aefcb7746acd86b970c007087f2c63170974d6a8e6f73b4182d04d91b3686e","6ec6a08562cdb219126a4d46da5c30c977594a7ef0cb2b4bf3f4cba209af1509",{"version":"85855bc76fb6cf3bf5a505bebef64a36e6f3636f4b0955fa21e9cd7bf54bc2f8","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"d96ba36a40776bd518131e0abdf49aa6ad43e392471273a21b41acb9ab5a1513","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},"57e09995277db76596b3e1f4b006ae2f0d75b9393ff371fd5fb203e90ba2582d",{"version":"f9751dba1d201be79d6e9974a3bb641402eb056ab2e449efac02557e9e4578a5","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"21944c138a48dc23382cb6558b1d4498908faad2104ba7ff390ba8b27c06f3c0","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"bd0e57158bd69732d3199a1d287435d52057e1687c760ae2c21af234b2ec7672","impliedFormat":99},{"version":"69d4b61c408556b97b796782a1110f7e01a03ed80f31741f2c59b722185830ed","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"e6ca59368dce5a594dcde9bbb6ae640d668fa6c28c31639dd2a75b731bb036a2","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},"2820aa21ed06cf37bead1aa22e6b2f05a90a8f69d430d3f63a75bdc10ad0d6a6"],"root":[[62,66],[69,81],[190,192],[194,218],259],"options":{"allowImportingTsExtensions":true,"composite":true,"esModuleInterop":true,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":false,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[259,1],[67,2],[68,2],[254,3],[252,2],[189,4],[188,2],[257,5],[253,3],[255,6],[256,3],[219,2],[134,7],[135,7],[136,8],[87,9],[137,10],[138,11],[139,12],[82,2],[85,13],[83,2],[84,2],[140,14],[141,15],[142,16],[143,17],[144,18],[145,19],[146,19],[147,20],[148,21],[149,22],[150,23],[88,2],[86,2],[151,24],[152,25],[153,26],[187,27],[154,28],[155,2],[156,29],[157,30],[158,31],[159,32],[160,33],[161,34],[162,35],[163,36],[164,37],[165,37],[166,38],[167,2],[168,39],[169,40],[171,41],[170,42],[172,43],[173,44],[174,45],[175,46],[176,47],[177,48],[178,49],[179,50],[180,51],[181,52],[182,53],[183,54],[184,55],[89,2],[90,2],[91,2],[130,56],[131,2],[132,2],[133,43],[185,57],[186,58],[258,59],[92,2],[251,60],[193,61],[226,2],[243,62],[241,63],[242,64],[230,65],[231,63],[238,66],[229,67],[234,68],[244,2],[235,69],[240,70],[246,71],[245,72],[228,73],[236,74],[237,75],[232,76],[239,62],[233,77],[221,78],[220,79],[227,2],[60,2],[61,2],[12,2],[11,2],[2,2],[13,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[3,2],[21,2],[22,2],[4,2],[23,2],[27,2],[24,2],[25,2],[26,2],[28,2],[29,2],[30,2],[5,2],[31,2],[32,2],[33,2],[34,2],[6,2],[38,2],[35,2],[36,2],[37,2],[39,2],[7,2],[40,2],[45,2],[46,2],[41,2],[42,2],[43,2],[44,2],[8,2],[50,2],[47,2],[48,2],[49,2],[51,2],[9,2],[52,2],[53,2],[54,2],[56,2],[55,2],[57,2],[58,2],[10,2],[59,2],[1,2],[108,80],[118,81],[107,80],[128,82],[99,83],[98,84],[127,85],[121,86],[126,87],[101,88],[115,89],[100,90],[124,91],[96,92],[95,85],[125,93],[97,94],[102,95],[103,2],[106,95],[93,2],[129,96],[119,97],[110,98],[111,99],[113,100],[109,101],[112,102],[122,85],[104,103],[105,104],[114,105],[94,106],[117,97],[116,95],[120,2],[123,107],[250,108],[247,109],[225,110],[223,111],[222,2],[224,112],[248,2],[249,113],[64,114],[65,115],[71,116],[70,117],[80,118],[81,119],[192,120],[201,121],[73,122],[72,123],[74,124],[195,125],[79,126],[77,127],[78,128],[76,129],[75,2],[196,130],[191,131],[63,132],[198,133],[199,134],[200,135],[202,136],[69,137],[194,138],[203,2],[190,139],[66,2],[197,139],[62,2],[204,140],[205,141],[206,142],[207,143],[208,144],[209,145],[210,146],[211,147],[212,148],[213,149],[214,150],[215,151],[216,152],[217,153],[218,154]],"affectedFilesPendingEmit":[[259,17],[64,17],[65,17],[71,17],[70,17],[80,17],[81,17],[192,17],[201,17],[73,17],[72,17],[74,17],[195,17],[79,17],[77,17],[78,17],[76,17],[75,17],[196,17],[191,17],[63,17],[198,17],[199,17],[200,17],[202,17],[69,17],[194,17],[203,17],[190,17],[66,17],[197,17],[62,17],[204,17],[205,17],[206,17],[207,17],[208,17],[209,17],[210,17],[211,17],[212,17],[213,17],[214,17],[215,17],[216,17],[217,17],[218,17]],"emitSignatures":[62,63,64,65,66,69,70,71,72,73,74,75,76,77,78,79,80,81,190,191,192,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,259],"version":"5.9.3"} \ No newline at end of file diff --git a/launcher/tsconfig.web.tsbuildinfo b/launcher/tsconfig.web.tsbuildinfo deleted file mode 100644 index 95109c7..0000000 --- a/launcher/tsconfig.web.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./src/shared/protocol.ts","./src/shared/project.ts","./locales/en.json","./locales/fr.json","./src/shared/i18n.ts","./src/shared/ipc.ts","./src/renderer/src/previews.ts","./src/renderer/src/result.ts","./src/renderer/src/session.ts","./src/renderer/src/i18n.tsx","./src/renderer/src/views/versionsview.tsx","./src/renderer/src/components/icon.tsx","./src/shared/model-preview.ts","./src/renderer/src/components/modelpreview.tsx","./src/renderer/src/views/projectview.tsx","./src/shared/settings-schema.ts","./src/renderer/src/components/settingsform.tsx","./src/renderer/src/components/texturepanel.tsx","./src/renderer/src/components/quicknewentry.tsx","./src/renderer/src/components/entryheader.tsx","./src/renderer/src/views/editorview.tsx","./src/renderer/src/views/logsview.tsx","./src/renderer/src/views/exportview.tsx","./src/renderer/src/views/settingsview.tsx","./src/renderer/src/components/statusbar.tsx","./src/renderer/src/components/quicklaunch.tsx","./src/renderer/src/components/crashdialog.tsx","./src/renderer/src/app.tsx","./node_modules/@types/react-dom/client.d.ts","./src/renderer/src/main.tsx","./src/shared/version.ts","./src/preload/index.d.ts"],"fileIdsList":[[67],[66],[64,65],[73],[66,67,76,77,78,79,82,88,89,90,91,92,93,94],[66,67,73,77],[66,67,69,73,77,81],[66,67,73,80],[66,67,73,76,77,79],[66,67,68,69,77],[67,68,77,83],[67,76,77],[66,67,73,77,79],[66,67,72],[66,67,77,95,96],[66,67,73],[67,73],[66,67,68,69,73],[66,67,74,76,77,79,84,85,86,87],[66,67,73,76,77],[66,67,76,77],[66,67,68,69,73,74,75,76,77,79,81],[66,67,72,73,77],[66,67,73,75,76,77],[67,70,71],[67,68,69,72],[67,68]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"814d5c7384f3ca276e9dc4bcfde5545801a3ea0bfae09916b3336774e662fd1b","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},"7f93ec79df97ce06a9f7e2d87091f4ff14f7fb6a882375abbca1e488bae5c2c0","0230fd5c0a569a1ab873df98051bac5e163e06adb8669f8713e794afd48f0e02",{"version":"0e25dd83bfa28c7f5fb9a961505623c1d17bc35569b087878471f83e5556e3e0","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},{"version":"980796b30a30050db7b81611ecff08804c466058d15d2bef1134b1e1422d2ef8","signature":"07272b21a572d95614fca23c333f90b30911e188fd27e9bb9b3d3dd66602c5eb"},"2fcc52ac9003375e35b45f3c4e05912549ccf7a94bdf28ebf81fb9658afcf376",{"version":"cdf0d035d910b844495d5dc3464d7cbb65031bc2d70d6b3627be67789fe50901","signature":"4f43bd59fe1ea546130724ea137f99f829ba1f2e540b0f7903fe4afb9d9a33e6"},"a45b3ab8f9b028efd38e16e1fa8a79b1f31fef20711d1417b61898c6fa0af390","cd615e1735a11f27d12a0008e81d6bca3edc9e5644e73090b08a7e0054517a23","b599845d38c63f6c906ea040de2527f702d17b3b225c0202d0e0737988ca68a9","ef5fadef7d679d9101387611988e9dd30324f198f78dc2708d228323c1a619b9","be70801de725ffcf456b1929d2997b1eed405b81324a2b4ffce6798c118cd9cb",{"version":"cd89fcda5db4feee443d955464d417c74418c5c4331f829e7f59cc3f1e977465","signature":"cc1243040c70550943a85531b7e2fb42eb1b4a7617eb6bbc85e7234311f96489"},"91e985d8f993207fa3192dbd8fbff4d80065d9cfd2a1dde31b096d8dbb8871c8","e976204aaad50c2068bbf60210e4d44d038c7e6383f1d5576855c60957f2a37b",{"version":"d832dfb3e476d72a1c5d8507c83eb6cb0172edaa70c3380eef4374a064ac0c27","signature":"c731a053e2f85fb978df3ee92d073c5c60a323812121e02434e9f54fb264c5d0"},"653d38e051e88413fb0adb044643723bf5b73ac17df0edc913bce8516c5b5772","da9d36459d6ce8c1d00e29d8137473eb7473f0b53deccd900ee776575ae6db63",{"version":"dedf429b9d55862f3edfd39d428b7a76fc06b9b6adf992c92864d3d839996f4a","signature":"e2d0aa1c41eb2d6340798dea8280c85c3de3f1fc752b05748b1dedf4c2e6af4a"},"c6d924d1b1f45277a1240b142db5d15cbd56647fb3e69ef8bf45464a301d51a1","ea632c1d26f2246050cf198c3828791a570330fe70a68e456feea06864dfdd88",{"version":"8e001955f37f55dda1f66bfeccd07bd83a7ebfc473ae5f6c6c88d17a100f7a25","signature":"bb2bc9d868b03b473f518a2d231e764da5664137e061dffd06d984cc4baad67c"},"7c6fc9f2a802f48dab615e49ee3ef1af4cde4a2d1c215a1b595f45f84db50e15","4d62c3900d5996682de2311422f43e1853e98678769fb5c47265c26b224cb1d0","e88e21ff11895adfe2424c5cf76ba30d30ae704fa54f523f34e0813db21c7517","b828db1821729b2a3a94fa11dde578c411677090665f64adfa1407be92ba1166",{"version":"fdd736ba377bc76db397067645a52cd235769493c22164b71f766293b5dff6fb","signature":"3574545769654249cf615c708b4d3262fffa0c37319e0eb4203809c6960f4e27"},"019f77e4b23fecf52ac74b591706dbcb8fdf884e695b5500ed5e2bc43d372c55",{"version":"795920053edc0eaec6f11a8b72c1f1864b13d643c5d9bed4c68cfa33b52da62c","signature":"5ea98aa344d44b6d7c0dfdc744ff10a4a3a40429a11e505bb52e1204f382c600"},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},"b8dab75d9f8329e5afb4167c0dd3deb244fc995531cbb86a60bcc28ebb35a14e","6d738b4fe64ce86e7f2b349b816af4c33bc1135c684e044da17a0939a4236a11",{"version":"30f07285f58c550003df14dbd757001514739ad959142ed589afdc206f2e6494","affectsGlobalScope":true}],"root":[68,69,[72,95],[97,99]],"options":{"allowImportingTsExtensions":true,"composite":true,"esModuleInterop":true,"jsx":4,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":false,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[70,1],[71,1],[96,2],[66,3],[67,2],[99,4],[95,5],[94,6],[87,7],[79,1],[81,8],[93,9],[86,10],[84,11],[92,12],[85,13],[77,14],[97,15],[74,16],[75,17],[76,18],[88,19],[90,20],[89,21],[82,22],[91,23],[78,24],[72,25],[73,26],[80,1],[69,27],[68,1],[83,27],[98,1]],"affectedFilesPendingEmit":[[95,17],[94,17],[87,17],[79,17],[81,17],[93,17],[86,17],[84,17],[92,17],[85,17],[77,17],[97,17],[74,17],[75,17],[76,17],[88,17],[90,17],[89,17],[82,17],[91,17],[78,17],[72,17],[73,17],[80,17],[69,17],[68,17],[83,17],[98,17]],"emitSignatures":[68,69,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,97,98],"version":"5.9.3"} \ No newline at end of file diff --git a/mod/adapters/forge-1.12.2/build.gradle b/mod/adapters/forge-1.12.2/build.gradle index 54fb23e..9c9fefc 100644 --- a/mod/adapters/forge-1.12.2/build.gradle +++ b/mod/adapters/forge-1.12.2/build.gradle @@ -17,7 +17,7 @@ plugins { } group = 'dev.ella' -version = '0.1.0' +version = '0.2.0' archivesBaseName = 'ella-forge-1.12.2' java { diff --git a/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java b/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java index 7ef5434..d794f97 100644 --- a/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java +++ b/mod/adapters/forge-1.12.2/src/main/java/dev/ella/forge112/EllaMod.java @@ -15,17 +15,26 @@ * render layers, live reloads — is a client concern. Marking it so keeps it from being * demanded of servers. */ +/* + * `acceptedMinecraftVersions` must match this adapter's entry in ADAPTERS + * (launcher/src/shared/version.ts), and a launcher test fails if the two drift. + * + * Stating it matters even when the range looks obvious: with the attribute absent FML + * derives the range from `mcversion` in mcmod.info and accepts only that exact version, + * so the launcher's offer of 1.12 and 1.12.1 would have been refused by the loader. + */ @Mod( modid = EllaMod.MOD_ID, name = "Ella", version = EllaMod.VERSION, clientSideOnly = true, + acceptedMinecraftVersions = "[1.12,1.13)", acceptableRemoteVersions = "*" ) public final class EllaMod { public static final String MOD_ID = "ella"; - public static final String VERSION = "0.1.0"; + public static final String VERSION = "0.2.0"; /** Default pool size, overridable with {@code -Della.slots.block} / {@code .item}. */ private static final int DEFAULT_SLOTS = 128; diff --git a/mod/adapters/forge-1.8.9/build.gradle b/mod/adapters/forge-1.8.9/build.gradle new file mode 100644 index 0000000..84e258e --- /dev/null +++ b/mod/adapters/forge-1.8.9/build.gradle @@ -0,0 +1,78 @@ +/* + * Ella adapter for Minecraft 1.8.9 on Forge. + * + * Unlike the other two adapters, this one runs on the era-correct toolchain rather than a + * modern one. RetroFuturaGradle — which lets 1.12.2 build on Gradle 8 — supports exactly + * two Minecraft versions, 1.7.10 and 1.12.2, because those are the ones its authors ship + * modpacks for. 1.8.9 is not one of them and no version of RFG adds it, so the only route + * is ForgeGradle 2.1, which is pinned to Gradle 2.x and Java 8. + * + * That is why this directory carries its own wrapper: `./gradlew` here launches Gradle + * 2.14.1, not the 8.2.1 the rest of the repository uses. The adapters were always + * independent builds for exactly this reason. + */ + +buildscript { + repositories { + maven { url = 'https://maven.minecraftforge.net' } + maven { url = 'https://repo.maven.apache.org/maven2' } + mavenCentral() + } + dependencies { + classpath 'net.minecraftforge.gradle:ForgeGradle:2.1-SNAPSHOT' + } +} + +apply plugin: 'net.minecraftforge.gradle.forge' + +group = 'dev.ella' +version = '0.2.0' +archivesBaseName = 'ella-forge-1.8.9' + +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +minecraft { + version = '1.8.9-11.15.1.2318-1.8.9' + runDir = 'run' + // MCP mappings for 1.8.9: `stable_22` is the last stable channel published for it. + mappings = 'stable_22' +} + +/* + * ella-core is compiled from source rather than consumed as a jar, so an adapter can + * never be built against a stale copy of the shared code. + */ +sourceSets { + main { + java { + srcDir file('../../ella-core/src/main/java') + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Gson comes from Minecraft at runtime; compiling against it must not bundle a copy. + compileOnly 'com.google.code.gson:gson:2.8.0' +} + +processResources { + inputs.property 'version', project.version + + filesMatching('mcmod.info') { + expand 'version': project.version, 'mcversion': '1.8.9' + } +} + +jar { + manifest { + attributes( + 'FMLCorePluginContainsFMLMod': 'true', + 'Implementation-Version': project.version + ) + } +} diff --git a/mod/adapters/forge-1.8.9/gradle.properties b/mod/adapters/forge-1.8.9/gradle.properties new file mode 100644 index 0000000..4594432 --- /dev/null +++ b/mod/adapters/forge-1.8.9/gradle.properties @@ -0,0 +1,8 @@ +# Deobfuscating the 1.8.9 jar holds the whole class set in memory at once. Gradle 2.14's +# default heap is far too small for it and the build dies with "GC overhead limit +# exceeded" partway through `deobfMcMCP`. +org.gradle.jvmargs=-Xmx3G + +# Deliberately not setting org.gradle.java.home: this build needs a Java 8 JVM, but the +# path to one differs per machine. Pass it at the command line instead — +# `./gradlew build -Dorg.gradle.java.home=/path/to/jdk-8` — or export JAVA_HOME. diff --git a/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.jar b/mod/adapters/forge-1.8.9/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..033e24c4cdf41af1ab109bc7f253b2b887023340 GIT binary patch literal 63375 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≫L+crb^Bz zc+Nw%zgpZ6>!A3%lau!Pw6`Y#WPVBtAfKSsqwYDWQK-~ zz(mx=nJ6-8t`YXB{6gaZ%G}Dmn&o500Y}2Rd?e&@=hBEmB1C=$OMBfxX__2c2O4K2#(0ksclP$SHp*8jq-1&(<6(#=6&H`Nlc2RVC4->r6U}sTY<1? zn@tv7XwUs-c>Lcmrm5AE0jHI5={WgHIow6cX=UK)>602(=arbuAPZ37;{HTJSIO%9EL`Et5%J7$u_NaC(55x zH^qX^H}*RPDx)^c46x>js=%&?y?=iFs^#_rUl@*MgLD92E5y4B7#EDe9yyn*f-|pQ zi>(!bIg6zY5fLSn@;$*sN|D2A{}we*7+2(4&EhUV%Qqo5=uuN^xt_hll7=`*mJq6s zCWUB|s$)AuS&=)T&_$w>QXHqCWB&ndQ$y4-9fezybZb0bYD^zeuZ>WZF{rc>c4s`` zgKdppTB|o>L1I1hAbnW%H%EkFt%yWC|0~+o7mIyFCTyb?@*Ho)eu(x`PuO8pLikN> z6YeI`V?AUWD(~3=8>}a6nZTu~#QCK(H0+4!ql3yS`>JX;j4+YkeG$ZTm33~PLa3L} zksw7@%e-mBM*cGfz$tS4LC^SYVdBLsR}nAprwg8h2~+Cv*W0%izK+WPVK}^SsL5R_ zpA}~G?VNhJhqx2he2;2$>7>DUB$wN9_-adL@TqVLe=*F8Vsw-yho@#mTD6*2WAr6B zjtLUh`E(;#p0-&$FVw(r$hn+5^Z~9J0}k;j$jL1;?2GN9s?}LASm?*Rvo@?E+(}F& z+=&M-n`5EIz%%F^e)nnWjkQUdG|W^~O|YeY4Fz}>qH2juEere}vN$oJN~9_Th^&b{ z%IBbET*E8%C@jLTxV~h#mxoRrJCF{!CJOghjuKOyl_!Jr?@4Upo7u>fTGtfm|CH2v z&9F+>;6aFbYXLj3{yZ~Yn1J2%!)A3~j2$`jOy{XavW@t)g}}KUVjCWG0OUc7aBc=2 zR3^u=dT47=5SmT{K1aGaVZkOx|24T-J0O$b9dfB25J|7yb6frwS6wZ1^y%EWOm}S< zc1SdYhfsdLG*FB-;!QLV3D!d~hnXTGVQVck9x%=B(Kk8c3y%f0nR95_TbY;l=obSl zEE@fp0|8Q$b3(+DXh?d0FEloGhO0#11CLQT5qtEckBLe-VN-I>9ys}PVK0r;0!jIG zH_q$;a`3Xv9P_V2ekV1SMzd#SKo<1~Dq2?M{(V;AwhH_2x@mN$=|=cG0<3o^j_0OF z7|WJ-f2G=7sA4NVGU2X5`o*D2T7(MbmZ2(oipooE{R?9!{WxX!%ofhsrPAxoIk!Kr z>I$a{Zq=%KaLrDCIL^gmA3z{2z%Wkr)b$QHcNUA^QwydWMJmxymO0QS22?mo%4(Md zgME(zE}ub--3*wGjV`3eBMCQG-@Gel1NKZDGuqobN|mAt0{@ZC9goI|BSmGBTUZ(`Xt z^e2LiMg?6E?G*yw(~K8lO(c4)RY7UWxrXzW^iCg-P41dUiE(i+gDmmAoB?XOB}+Ln z_}rApiR$sqNaT4frw69Wh4W?v(27IlK$Toy<1o)GeF+sGzYVeJ`F)3`&2WDi^_v67 zg;@ehwl3=t+}(DJtOYO!s`jHyo-}t@X|U*9^sIfaZfh;YLqEFmZ^E;$_XK}%eq;>0 zl?+}*kh)5jGA}3daJ*v1knbW0GusR1+_xD`MFPZc3qqYMXd>6*5?%O5pC7UVs!E-` zuMHc6igdeFQ`plm+3HhP)+3I&?5bt|V8;#1epCsKnz0%7m9AyBmz06r90n~9o;K30 z=fo|*`Qq%dG#23bVV9Jar*zRcV~6fat9_w;x-quAwv@BkX0{9e@y0NB(>l3#>82H6 z^US2<`=M@6zX=Pz>kb8Yt4wmeEo%TZ=?h+KP2e3U9?^Nm+OTx5+mVGDvgFee%}~~M zK+uHmj44TVs}!A}0W-A92LWE%2=wIma(>jYx;eVB*%a>^WqC7IVN9{o?iw{e4c=CG zC#i=cRJZ#v3 zF^9V+7u?W=xCY%2dvV_0dCP%5)SH*Xm|c#rXhwEl*^{Ar{NVoK*H6f5qCSy`+|85e zjGaKqB)p7zKNKI)iWe6A9qkl=rTjs@W1Crh(3G57qdT0w2ig^{*xerzm&U>YY{+fZbkQ#;^<$JniUifmAuEd^_M(&?sTrd(a*cD! zF*;`m80MrZ^> zaF{}rDhEFLeH#`~rM`o903FLO?qw#_Wyb5}13|0agjSTVkSI6Uls)xAFZifu@N~PM zQ%o?$k)jbY0u|45WTLAirUg3Zi1E&=G#LnSa89F3t3>R?RPcmkF}EL-R!OF_r1ZN` z?x-uHH+4FEy>KrOD-$KHg3$-Xl{Cf0;UD4*@eb~G{CK-DXe3xpEEls?SCj^p z$Uix(-j|9f^{z0iUKXcZQen}*`Vhqq$T?^)Ab2i|joV;V-qw5reCqbh(8N)c%!aB< zVs+l#_)*qH_iSZ_32E~}>=wUO$G_~k0h@ch`a6Wa zsk;<)^y=)cPpHt@%~bwLBy;>TNrTf50BAHUOtt#9JRq1ro{w80^sm-~fT>a$QC;<| zZIN%&Uq>8`Js_E((_1sewXz3VlX|-n8XCfScO`eL|H&2|BPZhDn}UAf_6s}|!XpmUr90v|nCutzMjb9|&}#Y7fj_)$alC zM~~D6!dYxhQof{R;-Vp>XCh1AL@d-+)KOI&5uKupy8PryjMhTpCZnSIQ9^Aq+7=Mb zCYCRvm4;H=Q8nZWkiWdGspC_Wvggg|7N`iED~Eap)Th$~wsxc(>(KI>{i#-~Dd8iQ zzonqc9DW1w4a*}k`;rxykUk+~N)|*I?@0901R`xy zN{20p@Ls<%`1G1Bx87Vm6Z#CA`QR(x@t8Wc?tpaunyV^A*-9K9@P>hAWW9Ev)E$gb z<(t?Te6GcJX2&0% z403pe>e)>m-^qlJU^kYIH)AutgOnq!J>FoMXhA-aEx-((7|(*snUyxa+5$wx8FNxS zKuVAVWArlK#kDzEM zqR?&aXIdyvxq~wF?iYPho*(h?k zD(SBpRDZ}z$A})*Qh!9&pZZRyNixD!8)B5{SK$PkVET(yd<8kImQ3ILe%jhx8Ga-1 zE}^k+Eo^?c4Y-t2_qXiVwW6i9o2qosBDj%DRPNT*UXI0=D9q{jB*22t4HHcd$T&Xi zT=Vte*Gz2E^qg%b7ev04Z&(;=I4IUtVJkg<`N6i7tjUn-lPE(Y4HPyJKcSjFnEzCH zPO(w%LmJ_=D~}PyfA91H4gCaf-qur3_KK}}>#9A}c5w@N;-#cHph=x}^mQ3`oo`Y$ope#)H9(kQK zGyt<7eNPuSAs$S%O>2ElZ{qtDIHJ!_THqTwcc-xfv<@1>IJ;YTv@!g-zDKBKAH<

Zet1e^8c}8fE97XH}+lF{qbF<`Y%dU|I!~Y`ZrVfKX82i z)(%!Tcf~eE^%2_`{WBPGPU@1NB5SCXe1sAI<4&n1IwO{&S$ThWn37heGOSW%nW7*L zxh0WK!E7zh%6yF-7%~l@I~b`2=*$;RYbi(I#zp$gL_d39U4A)KuB( zcS0bt48&%G_I~( zL(}w&2NA6#$=|g)J+-?ehHflD^lr77ngdz=dszFI;?~ZxeJv=gsm?4$$6#V==H{fa zqO!EkT>1-OQSJoX)cN}XsB;shvrHRwTH(I2^Ah4|rizn!V7T7fLh~Z<`Q+?zEMVxh z$=-x^RR*PlhkV_8mshTvs+zmZWY&Jk{9LX0Nx|+NAEq-^+Rh|ZlinVZ=e8=`WQt;e@= zPU}^1cG*O;G7l{Y#nl znp`y%CO_SC7gk0i0gY&phM04Y)~vU0!3$V$2T+h(1ZS+cCgc zaC?3M;B48^faGo>h~--#FNFauH?0BJJ6_nG5qOlr>k~%DCSJaOfl%KWHusw>tGrTxAhlEVDxc8R2C-)LCt&$Rt9IKor=ml7jirX@?WW+M z^I{b}MD5r$s>^^sN@&g`cXD~S_u09xo;{;noKZatIuzqd zW1e7oTl9>g8opPBT(p+&fo0F#!c{NFYYpIZ6u8hOB{F#{nP)@})X20$3iJtG$cO zJ$Oxl_qH{sL5d?=D$2M4C3Ajc;GN0(B-HVT;@pJ-LvIrN%|SY?t}g!J>ufQrR%hoY z!nr$tq~N%)9}^tEip93XW=MQ1@XovSvn`PTqXeT9@_7hGv4%LK1M**Q%UKi|(v@1_ zKGe*@+1%Y4v&`;5vUL`C&{tc+_7HFs7*OtjY8@Gg`C4O&#An{0xOvgNSehTHS~_1V z=daxCMzI5b_ydM5$z zZl`a{mM}i@x;=QyaqJY&{Q^R*^1Yzq!dHH~UwCCga+Us~2wk59ArIYtSw9}tEmjbo z5!JA=`=HP*Ae~Z4Pf7sC^A3@Wfa0Ax!8@H_&?WVe*)9B2y!8#nBrP!t1fqhI9jNMd zM_5I)M5z6Ss5t*f$Eh{aH&HBeh310Q~tRl3wCEcZ>WCEq%3tnoHE)eD=)XFQ7NVG5kM zaUtbnq2LQomJSWK)>Zz1GBCIHL#2E>T8INWuN4O$fFOKe$L|msB3yTUlXES68nXRX zP6n*zB+kXqqkpQ3OaMc9GqepmV?Ny!T)R@DLd`|p5ToEvBn(~aZ%+0q&vK1)w4v0* zgW44F2ixZj0!oB~^3k|vni)wBh$F|xQN>~jNf-wFstgiAgB!=lWzM&7&&OYS=C{ce zRJw|)PDQ@3koZfm`RQ$^_hEN$GuTIwoTQIDb?W&wEo@c75$dW(ER6q)qhF`{#7UTuPH&)w`F!w z0EKs}=33m}_(cIkA2rBWvApydi0HSOgc>6tu&+hmRSB%)s`v_NujJNhKLS3r6hv~- z)Hm@?PU{zd0Tga)cJWb2_!!9p3sP%Z zAFT|jy;k>4X)E>4fh^6=SxV5w6oo`mus&nWo*gJL zZH{SR!x)V)y=Qc7WEv-xLR zhD4OcBwjW5r+}pays`o)i$rcJb2MHLGPmeOmt5XJDg@(O3PCbxdDn{6qqb09X44T zh6I|s=lM6Nr#cGaA5-eq*T=LQ6SlRq*`~`b+dVi5^>el1p;#si6}kK}>w;1 z6B1dz{q_;PY{>DBQ+v@1pfXTd5a*^H9U*;qdj@XBF}MoSSQxVXeUpEM5Z0909&8$pRfR|B(t0ox&xl8{8mUNd#(zWONW{oycv$VjP1>q;jU@ z@+8E~fjz*I54OFFaQ{A5jn1w>r;l!NRlI(8q3*%&+tM?lov_G3wB`<}bQ>1=&xUht zmti5VZzV1Cx006Yzt|%Vwid>QPX8Nfa8|sue7^un@C+!3h!?-YK>lSfNIHh|0kL8v zbv_BklQ4HOqje|@Fyxn%IvL$N&?m(KN;%`I$N|muStjSsgG;gP4Smgz$2u(mG;DXP zf~uQ z212x^l6!MW>V@ORUGSFLAAjz3i5zO$=UmD_zhIk2OXUz^LkDLWjla*PW?l;`LLos> z7FBvCr)#)XBByDm(=n%{D>BcUq>0GOV9`i-(ZSI;RH1rdrAJ--f0uuAQ4odl z_^$^U_)0BBJwl@6R#&ZtJN+@a(4~@oYF)yG+G#3=)ll8O#Zv3SjV#zSXTW3h9kqn* z@AHL=vf~KMas}6{+u=}QFumr-!c=(BFP_dwvrdehzTyqco)m@xRc=6b#Dy+KD*-Bq zK=y*1VAPJ;d(b?$2cz{CUeG(0`k9_BIuUki@iRS5lp3=1#g)A5??1@|p=LOE|FNd; z-?5MLKd-5>yQ7n__5W^3C!_`hP(o%_E3BKEmo1h=H(7;{6$XRRW6{u+=oQX<((xAJ zNRY`Egtn#B1EBGHLy^eM5y}Jy0h!GAGhb7gZJoZI-9WuSRw)GVQAAcKd4Qm)pH`^3 zq6EIM}Q zxZGx%aLnNP1an=;o8p9+U^>_Bi`e23E^X|}MB&IkS+R``plrRzTE%ncmfvEW#AHJ~ znmJ`x&ez6eT21aLnoI`%pYYj zzQ?f^ob&Il;>6Fe>HPhAtTZa*B*!;;foxS%NGYmg!#X%)RBFe-acahHs3nkV61(E= zhekiPp1d@ACtA=cntbjuv+r-Zd`+lwKFdqZuYba_ey`&H<Psu;Tzwt;-LQxvv<_D5;ik7 zwETZe`+voUhk%$s2-7Rqfl`Ti_{(fydI(DAHKr<66;rYa6p8AD+NEc@Fd@%m`tiK% z=Mebzrtp=*Q%a}2UdK4J&5#tCN5PX>W=(9rUEXZ8yjRu+7)mFpKh{6;n%!bI(qA9kfyOtstGtOl zX!@*O0fly*L4k##fsm&V0j9Lj<_vu1)i?!#xTB7@2H&)$Kzt@r(GH=xRZlIimTDd_o(%9xO388LwC#;vQ?7OvRU_s< zDS@6@g}VnvQ+tn(C#sx0`J^T4WvFxYI17;uPs-Ub{R`J-NTdtBGl+Q>e81Z3#tDUr ztnVc*p{o|RNnMYts4pdw=P!uJkF@8~h)oV4dXu5F7-j0AW|=mt!QhP&ZV!!82*c7t zuOm>B*2gFtq;A8ynZ~Ms?!gEi5<{R_8tRN%aGM!saR4LJQ|?9w>Ff_61(+|ol_vL4 z-+N>fushRbkB4(e{{SQ}>6@m}s1L!-#20N&h%srA=L50?W9skMF9NGfQ5wU*+0<@> zLww8%f+E0Rc81H3e_5^DB@Dn~TWYk}3tqhO{7GDY;K7b*WIJ-tXnYM@z4rn(LGi?z z8%$wivs)fC#FiJh?(SbH-1bgdmHw&--rn7zBWe1xAhDdv#IRB@DGy}}zS%M0(F_3_ zLb-pWsdJ@xXE;=tpRAw?yj(Gz=i$;bsh&o2XN%24b6+?_gJDBeY zws3PE2u!#Cec>aFMk#ECxDlAs;|M7@LT8)Y4(`M}N6IQ{0YtcA*8e42!n^>`0$LFU zUCq2IR2(L`f++=85M;}~*E($nE&j;p{l%xchiTau*tB9bI= zn~Ygd@<+9DrXxoGPq}@vI1Q3iEfKRleuy*)_$+hg?+GOgf1r?d@Or42|s|D>XMa;ebr1uiTNUq@heusd6%WwJqyCCv!L*qou9l!B22H$bQ z)<)IA>Yo77S;|`fqBk!_PhLJEQb0wd1Z|`pCF;hol!34iQYtqu3K=$QxLW7(HFx~v>`vVRr zyqk^B4~!3F8t8Q_D|GLRrAbbQDf??D&Jd|mgw*t1YCd)CM2$76#Cqj1bD*vADwavp zS<`n@gLU4pwCqNPsIfHKl{5}gu9t-o+O< z??!fMqMrt$s}02pdBbOScUrc1T*{*-ideR6(1q4@oC6mxg8v8Y^h^^hfx6| z|Mld6Ax1CuSlmSJmHwdOix?$8emihK#&8&}u8m!#T1+c5u!H)>QW<7&R$eih)xkov zHvvEIJHbkt+2KQ<-bMR;2SYX?8SI=_<-J!GD5@P2FJ}K z5u82YFotCJF(dUeJFRX_3u8%iIYbRS??A?;iVO?84c}4Du9&jG<#urlZ_Unrcg8dR z!5I3%9F*`qwk#joKG_Q%5_xpU7|jm4h0+l$p;g%Tr>i74#3QnMXdz|1l2MQN$yw|5 zThMw15BxjWf2{KM)XtZ+e#N)ihlkxPe=5ymT9>@Ym%_LF}o z1XhCP`3E1A{iVoHA#|O|&5=w;=j*Qf`;{mBAK3={y-YS$`!0UmtrvzHBfR*s{z<0m zW>4C=%N98hZlUhwAl1X`rR)oL0&A`gv5X79??p_==g*n4$$8o5g9V<)F^u7v0Vv^n z1sp8{W@g6eWv2;A31Rhf5j?KJhITYfXWZsl^`7z`CFtnFrHUWiD?$pwU6|PQjs|7RA0o9ARk^9$f`u3&C|#Z3iYdh<0R`l2`)6+ z6tiDj@xO;Q5PDTYSxsx6n>bj+$JK8IPJ=U5#dIOS-zwyK?+t^V`zChdW|jpZuReE_ z)e~ywgFe!0q|jzsBn&(H*N`%AKpR@qM^|@qFai0};6mG_TvXjJ`;qZ{lGDZHScZk( z>pO+%icp)SaPJUwtIPo1BvGyP8E@~w2y}=^PnFJ$iHod^JH%j1>nXl<3f!nY9K$e` zq-?XYl)K`u*cVXM=`ym{N?z=dHQNR23M8uA-(vsA$6(xn+#B-yY!CB2@`Uz({}}w+ z0sni*39>rMC!Ay|1B@;al%T&xE(wCf+`3w>N)*LxZZZYi{5sqiVWgbNd>W*X?V}C- zjQ4F7e_uCUOHbtewQkq?m$*#@ZvWbu{4i$`aeKM8tc^ zL5!GL8gX}c+qNUtUIcps1S)%Gsx*MQLlQeoZz2y2OQb(A73Jc3`LmlQf0N{RTt;wa`6h|ljX1V7UugML=W5-STDbeWTiEMjPQ$({hn_s&NDXzs6?PLySp$?L`0ilH3vCUO{JS0Dp`z;Ry$6}R@1NdY7rxccbm$+;ApSe=2q!0 z()3$vYN0S$Cs)#-OBs{_2uFf}L4h$;7^2w20=l%5r9ui&pTEgg4U!FoCqyA6r2 zC5s72l}i*9y|KTjDE5gVlYe4I2gGZD)e`Py2gq7cK4at{bT~DSbQQ4Z4sl)kqXbbr zqvXtSqMrDdT2qt-%-HMoqeFEMsv~u)-NJ%Z*ipSJUm$)EJ+we|4*-Mi900K{K|e0; z1_j{X5)a%$+vM7;3j>skgrji92K1*Ip{SfM)=ob^E374JaF!C(cZ$R_E>Wv+?Iy9M z?@`#XDy#=z%3d9&)M=F8Xq5Zif%ldIT#wrlw(D_qOKo4wD(fyDHM5(wm1%7hy6euJ z%Edg!>Egs;ZC6%ktLFtyN0VvxN?*4C=*tOEw`{KQvS7;c514!FP98Nf#d#)+Y-wsl zP3N^-Pnk*{o(3~m=3DX$b76Clu=jMf9E?c^cbUk_h;zMF&EiVz*4I(rFoaHK7#5h0 zW7CQx+xhp}Ev+jw;SQ6P$QHINCxeF8_VX=F3&BWUd(|PVViKJl@-sYiUp@xLS2NuF z8W3JgUSQ&lUp@2E(7MG`sh4X!LQFa6;lInWqx}f#Q z4xhgK1%}b(Z*rZn=W{wBOe7YQ@1l|jQ|9ELiXx+}aZ(>{c7Ltv4d>PJf7f+qjRU8i%XZZFJkj&6D^s;!>`u%OwLa*V5Js9Y$b-mc!t@{C415$K38iVu zP7!{3Ff%i_e!^LzJWhBgQo=j5k<<($$b&%%Xm_f8RFC_(97&nk83KOy@I4k?(k<(6 zthO$3yl&0x!Pz#!79bv^?^85K5e7uS$ zJ33yka2VzOGUhQXeD{;?%?NTYmN3{b0|AMtr(@bCx+c=F)&_>PXgAG}4gwi>g82n> zL3DlhdL|*^WTmn;XPo62HhH-e*XIPSTF_h{#u=NY8$BUW=5@PD{P5n~g5XDg?Fzvb_u ziK&CJqod4srfY2T?+4x@)g9%3%*(Q2%YdCA3yM{s=+QD0&IM`8k8N&-6%iIL3kon> z0>p3BUe!lrz&_ZX2FiP%MeuQY-xVV%K?=bGPOM&XM0XRd7or< zy}jn_eEzuQ>t2fM9ict#ZNxD7HUycsq76IavfoNl$G1|t*qpUSX;YgpmJrr_8yOJ2 z(AwL;Ugi{gJ29@!G-mD82Z)46T`E+s86Qw|YSPO*OoooraA!8x_jQXYq5vUw!5f_x zubF$}lHjIWxFar8)tTg8z-FEz)a=xa`xL~^)jIdezZsg4%ePL$^`VN#c!c6`NHQ9QU zkC^<0f|Ksp45+YoX!Sv>+57q}Rwk*2)f{j8`d8Ctz^S~me>RSakEvxUa^Pd~qe#fb zN7rnAQc4u$*Y9p~li!Itp#iU=*D4>dvJ{Z~}kqAOBcL8ln3YjR{Sp!O`s=5yM zWRNP#;2K#+?I&?ZSLu)^z-|*$C}=0yi7&~vZE$s``IE^PY|dj^HcWI$9ZRm>3w(u` z-1%;;MJbzHFNd^!Ob!^PLO-xhhj@XrI81Y)x4@FdsI( za`o4Gy(`T$P?PB?s>o+eIOtuirMykbuAi65Y_UN1(?jTCy@J8Px`%;bcNmPm#Fr!= z5V!YViFJ!FBfEq>nJFk0^RAV1(7w+X`HRgP;nJHJdMa!}&vvduCMoslwHTes_I76|h>;(-9lbfGnt zoZomakOt759AuTX4b$)G8TzJ&m*BV8!vMs9#=e0tWa z%)84R=3?tfh72~=Rc;fXwj+x z+25xapYK@2@;}6)@8IL+F6iuJ_B{&A-0=U=U6WMbY>~ykVFp$XkH)f**b>TE5)shN z39E2L@JPCSl!?pkvFeh@6dCv9oE}|{GbbVM!XIgByN#md&tXy@>QscU0#z!I&X4;d z&B&ZA4lbrHJ!x4lCN4KC-)u#gT^cE{Xnhu`0RXVKn|j$vz8m}v^%*cQ{(h%FW8_8a zFM{$PirSI8@#*xg2T){A+EKX(eTC66Fb})w{vg%Vw)hvV-$tttI^V5wvU?a{(G}{G z@ob7Urk1@hDN&C$N!Nio9YrkiUC{5qA`KH*7CriaB;2~2Od>2l=WytBRl#~j`EYsj}jqK2xD*3 ztEUiPZzEJC??#Tj^?f)=sRXOJ_>5aO(|V#Yqro05p6)F$j5*wYr1zz|T4qz$0K(5! zr`6Pqd+)%a9Xq3aNKrY9843)O56F%=j_Yy_;|w8l&RU1+B4;pP*O_}X8!qD?IMiyT zLXBOOPg<*BZtT4LJ7DfyghK|_*mMP7a1>zS{8>?}#_XXaLoUBAz(Wi>$Q!L;oQ&cL z6O|T6%Dxq3E35$0g5areq9$2+R(911!Z9=wRPq-pju7DnN9LAfOu3%&onnfx^Px5( zT2^sU>Y)88F5#ATiVoS$jzC-M`vY8!{8#9O#3c&{7J1lo-rcNK7rlF0Zt*AKE(WN* z*o?Tv?Sdz<1v6gfCok8MG6Pzecx9?C zrQG5j^2{V556Hj=xTiU-seOCr2ni@b<&!j>GyHbv!&uBbHjH-U5Ai-UuXx0lcz$D7%=! z&zXD#Jqzro@R=hy8bv>D_CaOdqo6)vFjZldma5D+R;-)y1NGOFYqEr?h zd_mTwQ@K2veZTxh1aaV4F;YnaWA~|<8$p}-eFHashbWW6Dzj=3L=j-C5Ta`w-=QTw zA*k9!Ua~-?eC{Jc)xa;PzkUJ#$NfGJOfbiV^1au;`_Y8|{eJ(~W9pP9q?gLl5E6|e{xkT@s|Ac;yk01+twk_3nuk|lRu{7-zOjLAGe!)j?g+@-;wC_=NPIhk(W zfEpQrdRy z^Q$YBs%>$=So>PAMkrm%yc28YPi%&%=c!<}a=)sVCM51j+x#<2wz?2l&UGHhOv-iu z64x*^E1$55$wZou`E=qjP1MYz0xErcpMiNYM4+Qnb+V4MbM;*7vM_Yp^uXUuf`}-* z_2CnbQ);j5;Rz?7q)@cGmwE^P>4_u9;K|BFlOz_|c^1n~%>!uO#nA?5o4A>XLO{X2 z=8M%*n=IdnXQ}^+`DXRKM;3juVrXdgv79;E=ovQa^?d7wuw~nbu%%lsjUugE8HJ9zvZIM^nWvjLc-HKc2 zbj{paA}ub~4N4Vw5oY{wyop9SqPbWRq=i@Tbce`r?6e`?`iOoOF;~pRyJlKcIJf~G z)=BF$B>YF9>qV#dK^Ie#{0X(QPnOuu((_-u?(mxB7c9;LSS-DYJ8Wm4gz1&DPQ8;0 z=Wao(zb1RHXjwbu_Zv<=9njK28sS}WssjOL!3-E5>d17Lfnq0V$+IU84N z-4i$~!$V-%Ik;`Z3MOqYZdiZ^3nqqzIjLE+zpfQC+LlomQu-uNCStj%MsH(hsimN# z%l4vpJBs_2t7C)x@6*-k_2v0FOk<1nIRO3F{E?2DnS}w> z#%9Oa{`RB5FL5pKLkg59#x~)&I7GzfhiVC@LVFSmxZuiRUPVW*&2ToCGST0K`kRK) z02#c8W{o)w1|*YmjGSUO?`}ukX*rHIqGtFH#!5d1Jd}&%4Kc~Vz`S7_M;wtM|6PgI zNb-Dy-GI%dr3G3J?_yBX#NevuYzZgzZ!vN>$-aWOGXqX!3qzCIOzvA5PLC6GLIo|8 zQP^c)?NS29hPmk5WEP>cHV!6>u-2rR!tit#F6`_;%4{q^6){_CHGhvAs=1X8Fok+l zt&mk>{4ARXVvE-{^tCO?inl{)o}8(48az1o=+Y^r*AIe%0|{D_5_e>nUu`S%zR6|1 zu0$ov7c`pQEKr0sIIdm7hm{4K_s0V%M-_Mh;^A0*=$V9G1&lzvN9(98PEo=Zh$`Vj zXh?fZ;9$d!6sJRSjTkOhb7@jgSV^2MOgU^s2Z|w*e*@;4h?A8?;v8JaLPCoKP_1l- z=Jp0PYDf(d2Z`;O7mb6(_X_~z0O2yq?H`^c=h|8%gfywg#}wIyv&_uW{-e8e)YmGR zI0NNSDoJWa%0ztGzkwl>IYW*DesPRY?oH+ow^(>(47XUm^F`fAa0B~ja-ae$e>4-A z64lb_;|W0ppKI+ zxu2VLZzv4?Mr~mi?WlS-1L4a^5k+qb5#C)ktAYGUE1H?Vbg9qsRDHAvwJUN=w~AuT zUXYioFg2Dx-W)}w9VdFK#vpjoSc!WcvRZ_;TgHu;LSY*i7K_>Px{%C4-IL?6q?Qa_ zL7l=EEo|@X&$gX;fYP02qJF~LN9?E-OL2G(Fo4hW)G{`qnW zTIuc+-1VJvKgph0jAc(LzM);Pg$MPln?U|ek{_5nNJHfm-Y#ec+n#Yf_e>XfbLbN)eqHEDr0#?<;TskL5-0JGv|Ut{=$Xk8hlwbaMXdcI3GL zY-hykR{zX9liy$Z2F3!z346uu%9@-y6Gda`X2*ixlD_P@<}K?AoV?(%lM%* z(xNk=|A()443aGj)-~IDf3J+UA2p2lh6ei^pG*HL#SiThnIr5WZDXebI)F7X zGmP-3bH$i$+(IwqgbM7h%G5oJ@4{Z~qZ#Zs*k7eXJIqg;@0kAGV|b=F#hZs)2BYu1 zr8sj#Zd+Iu^G}|@-dR5S*U-;DqzkX3V0@q-k8&VHW?h0b0?tJ-Atqmg^J8iF7DP6k z)W{g?5~F*$5x?6W)3YKcrNu8%%(DglnzMx5rsU{#AD+WPpRBf``*<8F-x75D$$13U zcaNXYC0|;r&(F@!+E=%+;bFKwKAB$?6R%E_QG5Yn5xX#h+zeI-=mdXD5+D+lEuM`M ze+*G!zX^xbnA?~LnPI=D2`825Ax8rM()i*{G0gcV5MATV?<7mh+HDA7-f6nc@95st zzC_si${|&=$MUj@nLxl_HwEXb2PDH+V?vg zA^DJ%dn069O9TNK-jV}cQKh|$L4&Uh`?(z$}#d+{X zm&=KTJ$+KvLZv-1GaHJm{>v=zXW%NSDr8$0kSQx(DQ)6S?%sWSHUazXSEg_g3agt2@0nyD?A?B%9NYr(~CYX^&U#B4XwCg{%YMYo%e68HVJ7`9KR`mE*Wl7&5t71*R3F>*&hVIaZXaI;2a$?;{Ew{e3Hr1* zbf$&Fyhnrq7^hNC+0#%}n^U2{ma&eS)7cWH$bA@)m59rXlh96piJu@lcKl<>+!1#s zW#6L5Ov%lS(?d66-(n`A%UuiIqs|J|Ulq0RYq-m&RR0>wfA1?<34tI?MBI#a8lY{m z{F2m|A@=`DpZpwdIH#4)9$#H3zr4kn2OX!UE=r8FEUFAwq6VB?DJ8h59z$GXud$#+ zjneIq8uSi&rnG0IR8}UEn5OcZC?@-;$&Ry9hG{-1ta`8aAcOe1|82R7EH`$Qd3sf* zbrOk@G%H7R`j;hOosRVIP_2_-TuyB@rdj?(+k-qQwnhV3niH+CMl>ELX(;X3VzZVJ ztRais0C^L*lmaE(nmhvep+peCqr!#|F?iVagZcL>NKvMS_=*Yl%*OASDl3(mMOY9! z=_J$@nWpA-@><43m4olSQV8(PwhsO@+7#qs@0*1fDj70^UfQ(ORV0N?H{ceLX4<43 zEn)3CGoF&b{t2hbIz;Og+$+WiGf+x5mdWASEWIA*HQ9K9a?-Pf9f1gO6LanVTls)t z^f6_SD|>2Kx8mdQuiJwc_SmZOZP|wD7(_ti#0u=io|w~gq*Odv>@8JBblRCzMKK_4 zM-uO0Ud9>VD>J;zZzueo#+jbS7k#?W%`AF1@ZPI&q%}beZ|ThISf-ly)}HsCS~b^g zktgqOZ@~}1h&x50UQD~!xsW-$K~whDQNntLW=$oZDClUJeSr2$r3}94Wk1>co3beS zoY-7t{rGv|6T?5PNkY zj*XjF()ybvnVz5=BFnLO=+1*jG>E7F%&vm6up*QgyNcJJPD|pHoZ!H6?o3Eig0>-! zt^i-H@bJ;^!$6ZSH}@quF#RO)j>7A5kq4e+7gK=@g;POXcGV28Zv$jybL1J`g@wC# z_DW1ck}3+n@h2LFQhwVfaV@D+-kff4celZC0;0ef?pA#*PPd8Kk8sO1wza&BHQFblVU8P1=-qScHff^^fR zycH!hlHQs7iejITpc4UaBxzqTJ}Z#^lk{W(cr`qtW~Ap;HvuUf#MxgEG?tEU+B?G% znub0I(s@XvI(lva}$Z7<}Qg=rWd5n)}rX{nb+Aw;}?l9LZI-`N-*hts=c6XgjfJs ztp>-686v6ug{glEZ}K=jVG|N1WSWrU*&ue|4Q|O@;s0#L5P*U%Vx;)w7S0ZmLuvwA z@zs2Kut)n1K7qaywO#TbBR`Q~%mdr`V)D`|gN0!07C1!r3{+!PYf9*;h?;dE@#z(k z;o`g~<>P|Sy$ldHTUR3v=_X0Iw6F>3GllrFXVW?gU0q6|ocjd!glA)#f0G7i20ly>qxRljgfO2)RVpvmg#BSrN)GbGsrIb}9 z1t+r;Q>?MGLk#LI5*vR*C8?McB|=AoAjuDk&Pn`KQo z`!|mi{Cz@BGJ!TwMUUTkKXKNtS#OVNxfFI_Gfq3Kpw0`2AsJv9PZPq9x?~kNNR9BR zw#2jp%;FJNoOzW>tE#zskPICp>XSs?|B0E%DaJH)rtLA}$Y>?P+vEOvr#8=pylh zch;H3J`RE1{97O+1(1msdshZx$it^VfM$`-Gw>%NN`K|Tr$0}U`J?EBgR%bg=;et0 z_en)!x`~3so^V9-jffh3G*8Iy6sUq=uFq%=OkYvHaL~#3jHtr4sGM?&uY&U8N1G}QTMdqBM)#oLTLdKYOdOY%{5#Tgy$7QA! zWQmP!Wny$3YEm#Lt8TA^CUlTa{Cpp=x<{9W$A9fyKD0ApHfl__Dz4!HVVt(kseNzV z5Fb`|7Mo>YDTJ>g;7_MOpRi?kl>n(ydAf7~`Y6wBVEaxqK;l;}6x8(SD7}Tdhe2SR zncsdn&`eI}u}@^~_9(0^r!^wuKTKbs-MYjXy#-_#?F=@T*vUG@p4X+l^SgwF>TM}d zr2Ree{TP5x@ZtVcWd3++o|1`BCFK(ja-QP?zj6=ZOq)xf$CfSv{v;jCcNt4{r8f+m zz#dP|-~weHla%rsyYhB_&LHkwuj83RuCO0p;wyXsxW5o6{)zFAC~2%&NL? z=mA}szjHKsVSSnH#hM|C%;r0D$7)T`HQ1K5vZGOyUbgXjxD%4xbs$DAEz)-;iO?3& zXcyU*Z8zm?pP}w&9ot_5I;x#jIn^Joi5jBDOBP1)+p@G1U)pL6;SIO>Nhw?9St2UN zMedM(m(T6bNcPPD`%|9dvXAB&IS=W4?*7-tqldqALH=*UapL!4`2TM_{`W&pm*{?| z0DcsaTdGA%RN={Ikvaa&6p=Ux5ycM){F1OgOh(^Yk-T}a5zHH|=%Jk)S^vv9dY~`x zG+!=lsDjp!D}7o94RSQ-o_g#^CnBJlJ@?saH&+j0P+o=eKqrIApyR7ttQu*0 z1f;xPyH2--)F9uP2#Mw}OQhOFqXF#)W#BAxGP8?an<=JBiokg;21gKG_G8X!&Hv;7 zP9Vpzm#@;^-lf=6POs>UrGm-F>-! zm;3qp!Uw?VuXW~*Fw@LC)M%cvbe9!F(Oa^Y6~mb=8%$lg=?a0KcGtC$5y?`L5}*-j z7KcU8WT>2PpKx<58`m((l9^aYa3uP{PMb)nvu zgt;ia9=ZofxkrW7TfSrQf4(2juZRBgcE1m;WF{v1Fbm}zqsK^>sj=yN(x}v9#_{+C zR4r7abT2cS%Wz$RVt!wp;9U7FEW&>T>YAjpIm6ZSM4Q<{Gy+aN`Vb2_#Q5g@62uR_>II@eiHaay+JU$J=#>DY9jX*2A=&y8G%b zIY6gcJ@q)uWU^mSK$Q}?#Arq;HfChnkAOZ6^002J>fjPyPGz^D5p}o;h2VLNTI{HGg!obo3K!*I~a7)p-2Z3hCV_hnY?|6i`29b zoszLpkmch$mJeupLbt4_u-<3k;VivU+ww)a^ekoIRj4IW4S z{z%4_dfc&HAtm(o`d{CZ^AAIE5XCMvwQSlkzx3cLi?`4q8;iFTzuBAddTSWjfcZp* zn{@Am!pl&fv#k|kj86e$2%NK1G4kU=E~z9L^`@%2<%Dx%1TKk_hb-K>tq8A9bCDfW z@;Dc3KqLafkhN6414^46Hl8Tcv1+$q_sYjj%oHz)bsoGLEY1)ia5p=#eii(5AM|TW zA8=;pt?+U~>`|J(B85BKE0cB4n> zWrgZ)Rbu}^A=_oz65LfebZ(1xMjcj_g~eeoj74-Ex@v-q9`Q{J;M!mITVEfk6cn!u zn;Mj8C&3^8Kn%<`Di^~Y%Z$0pb`Q3TA}$TiOnRd`P1XM=>5)JN9tyf4O_z}-cN|i> zwpp9g`n%~CEa!;)nW@WUkF&<|wcWqfL35A}<`YRxV~$IpHnPQs2?+Fg3)wOHqqAA* zPv<6F6s)c^o%@YqS%P{tB%(Lxm`hsKv-Hb}MM3=U|HFgh8R-|-K(3m(eU$L@sg=uW zB$vAK`@>E`iM_rSo;Cr*?&wss@UXi19B9*0m3t3q^<)>L%4j(F85Ql$i^;{3UIP0c z*BFId*_mb>SC)d#(WM1%I}YiKoleKqQswkdhRt9%_dAnDaKM4IEJ|QK&BnQ@D;i-ame%MR5XbAfE0K1pcxt z{B5_&OhL2cx9@Sso@u2T56tE0KC`f4IXd_R3ymMZ%-!e^d}v`J?XC{nv1mAbaNJX| zXau+s`-`vAuf+&yi2bsd5%xdqyi&9o;h&fcO+W|XsKRFOD+pQw-p^pnwwYGu=hF7& z{cZj$O5I)4B1-dEuG*tU7wgYxNEhqAxH?p4Y1Naiu8Lt>FD%AxJ811`W5bveUp%*e z9H+S}!nLI;j$<*Dn~I*_H`zM^j;!rYf!Xf#X;UJW<0gic?y>NoFw}lBB6f#rl%t?k zm~}eCw{NR_%aosL*t$bmlf$u|U2hJ*_rTcTwgoi_N=wDhpimYnf5j!bj0lQ*Go`F& z6Wg+xRv55a(|?sCjOIshTEgM}2`dN-yV>)Wf$J58>lNVhjRagGZw?U9#2p!B5C3~Nc%S>p`H4PK z7vX@|Uo^*F4GXiFnMf4gwHB;Uk8X4TaLX4A>B&L?mw4&`XBnLCBrK2FYJLrA{*))0 z$*~X?2^Q0KS?Yp##T#ohH1B)y4P+rR7Ut^7(kCwS8QqgjP!aJ89dbv^XBbLhTO|=A z|3FNkH1{2Nh*j{p-58N=KA#6ZS}Ir&QWV0CU)a~{P%yhd-!ehF&~gkMh&Slo9gAT+ zM_&3ms;1Um8Uy0S|0r{{8xCB&Tg{@xotF!nU=YOpug~QlZRKR{DHGDuk(l{)d$1VD zj)3zgPeP%wb@6%$zYbD;Uhvy4(D|u{Q_R=fC+9z#sJ|I<$&j$|kkJiY?AY$ik9_|% z?Z;gOQG5I%{2{-*)Bk|Tia8n>TbrmjnK+8u*_cS%*;%>R|K|?urtIdgTM{&}Yn1;| zk`xq*Bn5HP5a`ANv`B$IKaqA4e-XC`sRn3Z{h!hN0=?x(kTP+fE1}-<3eL+QDFXN- z1JmcDt0|7lZN8sh^=$e;P*8;^33pN>?S7C0BqS)ow4{6ODm~%3018M6P^b~(Gos!k z2AYScAdQf36C)D`w&p}V89Lh1s88Dw@zd27Rv0iE7k#|U4jWDqoUP;-He5cd4V7Ql)4S+t>u9W;R-8#aee-Ct1{fPD+jv&zV(L&k z)!65@R->DB?K6Aml57?psj5r;%w9Vc3?zzGs&kTA>J9CmtMp^Wm#1a@cCG!L46h-j z8ZUL4#HSfW;2DHyGD|cXHNARk*{ql-J2W`9DMxzI0V*($9{tr|O3c;^)V4jwp^RvW z2wzIi`B8cYISb;V5lK}@xtm3NB;88)Kn}2fCH(WRH1l@3XaO7{R*Lc7{ZN1m+#&diI7_qzE z?BS+v<)xVMwt{IJ4yS2Q4(77II<>kqm$Jc3yWL42^gG6^Idg+y3)q$-(m2>E49-fV zyvsCzJ5EM4hyz1r#cOh5vgrzNGCBS}(Bupe`v6z{e z)cP*a8VCbRuhPp%BUwIRvj-$`3vrbp;V3wmAUt{?F z0OO?Mw`AS?y@>w%(pBO=0lohnxFWx`>Hs}V$j{XI2?}BtlvIl7!ZMZukDF7 z^6Rq2H*36KHxJ1xWm5uTy@%7;N0+|<>Up>MmxKhb;WbH1+=S94nOS-qN(IKDIw-yr zi`Ll^h%+%k`Yw?o3Z|ObJWtfO|AvPOc96m5AIw;4;USG|6jQKr#QP}+BLy*5%pnG2 zyN@VMHkD`(66oJ!GvsiA`UP;0kTmUST4|P>jTRfbf&Wii8~a`wMwVZoJ@waA{(t(V zwoc9l*4F>YUM8!aE1{?%{P4IM=;NUF|8YkmG0^Y_jTJtKClDV3D3~P7NSm7BO^r7& zWn!YrNc-ryEvhN$$!P%l$Y_P$s8E>cdAe3=@!Igo^0diL6`y}enr`+mQD;RC?w zb8}gXT!aC`%rdxx2_!`Qps&&w4i0F95>;6;NQ-ys;?j#Gt~HXzG^6j=Pv{3l1x{0( z4~&GNUEbH=9_^f@%o&BADqxb54EAq=8rKA~4~A!iDp9%eFHeA1L!Bb8Lz#kF(p#)X zn`CglEJ(+tr=h4bIIHlLkxP>exGw~{Oe3@L^zA)|Vx~2yNuPKtF^cV6X^5lw8hU*b zK-w6x4l&YWVB%0SmN{O|!`Sh6H45!7}oYPOc+a#a|n3f%G@eO)N>W!C|!FNXV3taFdpEK*A1TFGcRK zV$>xN%??ii7jx5D69O>W6O`$M)iQU7o!TPG*+>v6{TWI@p)Yg$;8+WyE9DVBMB=vnONSQ6k1v z;u&C4wZ_C`J-M0MV&MpOHuVWbq)2LZGR0&@A!4fZwTM^i;GaN?xA%0)q*g(F0PIB( zwGrCC#}vtILC_irDXI5{vuVO-(`&lf2Q4MvmXuU8G0+oVvzZp0Y)zf}Co0D+mUEZz zgwR+5y!d(V>s1} zji+mrd_6KG;$@Le2Ic&am6O+Rk1+QS?urB4$FQNyg2%9t%!*S5Ts{8j*&(H1+W;0~ z$frd%jJjlV;>bXD7!a-&!n52H^6Yp}2h3&v=}xyi>EXXZDtOIq@@&ljEJG{D`7Bjr zaibxip6B6Mf3t#-*Tn7p z96yx1Qv-&r3)4vg`)V~f8>>1_?E4&$bR~uR;$Nz=@U(-vyap|Jx zZ;6Ed+b#GXN+gN@ICTHx{=c@J|97TIPWs(_kjEIwZFHfc!rl8Ep-ZALBEZEr3^R-( z7ER1YXOgZ)&_=`WeHfWsWyzzF&a;AwTqzg~m1lOEJ0Su=C2<{pjK;{d#;E zr2~LgXN?ol2ua5Y*1)`(be0tpiFpKbRG+IK(`N?mIgdd9&e6vxzqxzaa`e7zKa3D_ zHi+c1`|720|dn(z4Qos^e7sn(PU%NYLv$&!|4kEse%DK;YAD06@XO3!EpKpz!^*?(?-Ip zC_Zlb(-_as+-D?0Ag9`|4?)bN)5o(J=&udAY|YgV(YuK9k=E>0z`$dSaL(wmxd!1f zME&3wwv@#{dgeMlZ4}GL!I`VZxtdQY$lmauCN_|mGXqEEj@i~du$|>5UvLjsbq!{; z@jEf;21iC1jFEmIPE^4gykHQzCMLj=2Ek4&FvlpqTlS(0YT%*W<>XgH$4ww`D`aihBGkPM(&EG};Cl&wzg8!jL z`rkqPzvH(0Kd{2n=?Bt8aAU&0IyiA+V-qnXVId^qG!SWZ7%_f&i!D{R#7Jo$%tICxY%j)ebORE>3H_c|to}c#HX;HAC?~B;2mmQrMp2;8T zmzde!k7BYg^Z1r|DUvSD3@{6S<1kndb%Qt%GA# z+sB2&F5L`R&fLRdAlpU_pVsJsYDEz{^ zKGaAz#%W+MPGT+D$+xowMY0=ipM)0p?zym&Aoi)qL(pO_weO(k?s|ELHl^W zviJiFUXRL&?`;3_;mvc02A@sbsW9}#{anvGafZ#ST;}za?XS3}ZG3B4m(SW{>w}Fh z)T5Yi*``Tstmi9SHXmuWSND@cj}qtY!`tuD29Dpu+-D3$h<5FY>jE>YJvqBmhw?oll`x7Ono(}R~P zle_eBwYy0Rr7kmf_SEt_gn4)AO-r`}^Z5Y%Rm8)K-?X>rvDL+QT?#)QwDsQ2c$tc* z&#hbgkL6}GnBDH;+lREM6MGIskRa@r>5Iq(ll2IepuhW86w@14=E{6$cz*cBDQ)CT>}v-DLM-v8)xaPBnmGBKM63RgDGqh!<*j90tSE4|G^+r@#-7g2 zs8KE8eZPZhQuN>wBU%8CmkE9LH1%O;-*ty0&K~01>F3XB>6sAm*m3535)9T&Fz}A4 zwGjZYVea@Fesd=Rv?ROE#q=}yfvQEP8*4zoEw4@^Qvw54utUfaR1T6gLmq?c9sON> z>Np6|0hdP_VURy81;`8{ZYS)EpU9-3;huFq)N3r{yP1ZBCHH7=b?Ig6OFK~%!GwtQ z3`RLKe8O&%^V`x=J4%^Oqg4ZN9rW`UQN^rslcr_Utzd-@u-Sm{rphS-y}{k41)Y4E zfzu}IC=J0JmRCV6a3E38nWl1G495grsDDc^H0Fn%^E0FZ=CSHB4iG<6jW1dY`2gUr zF>nB!y@2%rouAUe9m0VQIg$KtA~k^(f{C*Af_tOl=>vz>$>7qh+fPrSD0YVUnTt)? z;@1E0a*#AT{?oUs#bol@SPm0U5g<`AEF^=b-~&4Er)MsNnPsLb^;fL2kwp|$dwiE3 zNc5VDOQ%Q8j*d5vY##)PGXx51s8`0}2_X9u&r(k?s7|AgtW0LYbtlh!KJ;C9QZuz< zq>??uxAI1YP|JpN$+{X=97Cdu^mkwlB={`aUp+Uyu1P139=t%pSVKo7ZGi_v(0z>l zHLGxV%0w&#xvev)KCQ{7GC$nc3H?1VOsYGgjTK;Px(;o0`lerxB<+EJX9G9f8b+)VJdm(Ia)xjD&5ZL45Np?9 zB%oU;z05XN7zt{Q!#R~gcV^5~Y^gn+Lbad7C{UDX2Nznj8e{)TLH|zEc|{a#idm@z z6(zon+{a>FopmQsCXIs*4-dLGgTc)iOhO3r=l?imNUR-pWl!ktO0r_a0Nqo@bu8MzyjSq9zkqPe*`Sxz75rZ zr9X%(=PVqCRB=zfX+_u&*k4#s1k4OV11YgkCrlr6V;vz<{99HKC@qQ+H8xv5)sc63 z69;U4O&{fb5(fN``jJH#3=GHsV56@{d@7`VhA$K^;GU+R-V%%cnmjYs?>c5^6Ugv} zn<}L&i;2`zzW@(kxf$$gVH@7nh}2%G%ciQ_B?r{13?Q@=Q+6msQGtnyY%Gkjeor?g z7F*tMqLdhcq+LCCo^D;CtOACCBhXgK-M&w{*dcUdmtv@XFTofmmpcWKtCn^`#?oZC zUOm52 z7sK$hR|Vh6y&pfIUK&!`8HH*>12$nWA)Ynp+XwOj=jNLD z{QA4gezbe>wiP?`jJO;c&EId;=2u80s_r97;TX!6@*(<%WL+^bmxheMB3pKx0OpH^ zPs}knV+jpJ4TaD@r^V`mTsjf`7!z^H}eHQ#Rp z72(>Dm#QO!ZYR*O@yHic`3*T^t7jc=d`Jz6Lk@Y-bL%cOp_~=#xzIJl?`{Qu;$uC~NkePE+7wSW_FM`&V{gFN zl;lq@;FtAsl!h;tnOvj z#gYx!q$5MdZ0Jxjy=t*q)HFeeyI-vgaGdh1QNhqGRy8qS)|6S0QK7Gj9R?Co{Knh> za>xkQZ0}bBx!9@EUxRBYGm25^G}&j-`0VWX04E|J!kJ8^WoZ(jbhU_twFwWIH32fv zi=pg~(b#ajW=`)Vikwwe39lpML?|sY$?*6*kYBxku_<=#$gfTqQ_F!9F0=OkHnzBo zEwR!H_h|MNjuG$Tj6zaaouO}HYWCF8vN4C%EX-%Iu%ho;q$G#ErnafhXR*4J2Rp5* zhsi0;wlSwE*inVFO>{(8?N~82zijpt+9Y_-^>xnE%T*zk9gi|j7b@s<5{|qEquUD( zS;-%RySZOCOEh*>!kvbsQ265* z>X8*_Wy&~FB@aDHz%glyiAujXq-|2kDUjFTn9Rafsl+XNyFP%PG|l&ZGWBcEXxy=9 zeDn2PIoVuL$gX0RgVK1O$x3%pOzS7x^U5Pi;mtT)%cY;&e&M7GLM}zP+IPbqLt=^5 z7qLfri8myf;~2psc@^cA6mG&{C%e_(M$$!wC^5p^T1QzrS%I?(U{qcd+oJJkQxe10 zON{Q*?iz%F4MbEsoEc+x3E?&2wVR^v3|Q0lDaMvgS7mNjI{2w! z9|~=!83T%GW*iaChSS!`Xd^beFp9N4%K+k*j#jFumk}U?=WKL_kJAltxnxp~+lZzT zp@&&kSPTg3oSGos`rVBhK0|4NdHM_hnKuw1#0JV{gi_dKDJLB+ix~~HpU9%jD)@YY zOK)L7kgbLyN2%Dx#fuY}8swh4ACk7%BpP-n5(RhDq{gEHP*Fo4IviX{C49|B5h~SC zFr`=0)=h2^F5UpCAgt?R5u{6VvpUf#*nC zCQ`$!|C;L2lpjlG?(>T$(_$O3_YNNbPT~(?!j3aD8k=yu^ogw4bkjvgF|3BOq(hB& zG;^cPXmcUP$ox8zElCJ-zMbK9q^8{rri#8Cek5Ydr0YT-KTh@J z6^AcB9ejew8BY5kzZUZX(7Po==eW<(;uV~E7(BY5c0^xr`cuRwn)47bN?zOb!0?cw z#v}R$z66&m#+AHfo@(^V2#S~bhoUkkTArg+6w>JzZ52r96^({1W!?>4$h0l|-jDfj z>7(<+%67#(A|4hZ3>Y;hd&S?}F;`Vtqz|pK&B>NJ=Faci;gkf-+GmfQR8^zo_vul2 zB!)kfu4Dq_g)8TBBo52*sB6F`qa&JCR=_A$QWgX_K}fZm{Cb2#1q`^S3+WaS>sS#@ z-4k*G=#?z6d_e7JJ+Z8^(t0tNdL{K5F;2nfQbXgld}a(X)Gr;WojOy`^?es~AClT$ z5^lD{WJek0!p-QEH5E7n6DKQ0%_ZBZ=|jfV_MM{VmL8y-Wd|>OmeemP=C@xI@@M~1 zW2S*im@Rc=O>V886_UJ@oh1!2H$Ku&U*Hh_oxd{32)vf1$cRiepv28ricM;}#p!+k zaK{z1I=9Y%3m4|Pj*BD*Fn5Vh?O@oD^1UcjyeNh0fbhh~V6xb#4njlGW8OehUe!MnoR(wn#nsoyL1m!Rov)Nv4~&JEVl7L z#^qYdTpNI#u`N0UbVMiDmD>g2VQcG3>4D6gErgddZnSQTs){BExxRJRB?bIxTdZa z;!S8FHJPPiIDQ*FAUiWSYnjILFjDvxvSC zk z=j4Kx@Pg~&2Z?cmMDa;)#xVeorJrxDBqy{+`kG+ZPQqC@#ku-c3ucU+69$#q_*se` z-H#PFW^>-C0>++|6r=<$Z8)ZFaK=ZjwsNYXqRpl9G|yme@Eld5B-*I69Nx_TResHi z!5nm+>6zaJYQO#%D{~o-oOJ;q`fa5}l!8G*U-E$OM&7@dqciBCWtd}|SrDXz$TB($&m*=Epuolu2k`KUwO7maP3P0ok zmF57lSh0Ba@&sO1iZ5^+3s8{B8t|M;Pg&O+{tZJCiLWd6H@{b~9{CLF9s3Kn zt5)Rs9ejne?o{%f>B$Dl%X7fd~KY)I|(pxUeHj;gNsK6;ZR>`ciu;GxvhDUt!+31Knss2U(%ts8K z18)8;<2ax9RG?!|Lwdt^i5L^&O788roKmVAB)=EdK~HqR2Q=)H_VW}xY=95MP_Ov< zPEz3%DRK}+(aUBwsr83H8>`H^v~|A_t}0vPmRwKPt1{|qOY|PZu}j9+{ZhF&-H_TB zU9xWLpNTc`enI|)h9jQeqf5RfGLFk_vfX`40iMpd%KZF!lKbZTdBw$<^G6nuS+$fT zrbK)xo&;buPJcpOZ=x>n+bRXVFDs(23Xr=rDE&!)pVXZ;;A07NXGl_0m`{Z)DQIu$ zFDvY4xu-ifTe_$|n2B83eI;KUg6pVbw+N!nyLj~wnRi{4mNy{WDV)G1!6$y=+x6U{ z%4_9=Q^L!x_gAYp?J3+u5hA5cO8aHeI=6AC8^S{mzhqCBvBLYEutUC(X0>hKg|AvN zvkmJCQNA45_KjW{aEcyrBppcO6G0zTy%v1&@~+2!n?kA9?>0>AjFN|JdCnHQ8$hEU zw#mwGifHppLP?89LMb(Y3Li9iCPx7W%ek}2FgD2YSzjsR4Xj<=zN{Yo@7s7(k%mP4 znT2p&4EQ@q_chd-E z78uvD*C@oba`U3W2Iw`M#`5C8jOHv8^Li<|j^SI>>>`77Dp71Vtz=J?4Zck4SdRbd zfF}C_>Y(#)r@y!Q0`tMlG#b9>5`fAI$B&tWJfbGlYW$J4V+-s=HH!`+;1XeL@USdx zR0$G&&XBf9lQtkH5)p=U!8J!1{oc4E!N-~Abxl6E;;=3-hMYZ+44?u}zabmCE)yB?*_w91m$n1Yskp&@ z;kxeJX-#ioX^{elyLu~gzx|_KxLpX62MF%Axq3$!Z_P`pBWR?zP8OI`PV~6Aa0Oi0 zv_Ot1m&plf-ZF{e(z(Ms3*S5q$e|j;gOwGrmWsCHfLi(h8y?gc$(2H{884C1FvHQQ12tX=qFUsK~zM!W=K>;zaRsu4Xmcc@8nSs!vK+{ z?}bq}-m&p5jRSam67n>yG9ez=I^|J1O;Np8s=P~9MXYLxD+cFQK7PhG=bkjo{Naae zjp3NWWrlFWDb3Z5D07Q|WjZ=wOQ=aKA%en=O@hL$QCKpIXNZE=InFk|Fhq-&H!6&X z*MVy8=hL7Aw&pQjHrFf27C%3B<>FX{@fOLNhUoxL4*@nY}&M3G*T-p67a zo}~_&yGOB)#vbU|Q3FA8S^X)c-yBlmN(_%}`7Ha3uWFe?>9f=3hlO{^gv~$p`v?vk z_P*r43|(S{%ihs;)YH|jAMpP=-Ms7Ne75_YZZiL3CHVjSU`X1|?Ehh&gA=Xn7W7d@ zf8bM9Y>lG!`PWFDDA9G;x*{1Eh^55u66*9D+-4^dYZ{xXP@?sQLVrY%(azM;C^4FuN7CQ%$!3sr1JL=!Be& zuOZL^bLp$Qo2rL=WDzQIls%s!Go z{s}Q0b#+#8bKga|01t%^9Z=wEsevvXM_{$dCR97ed3@1kX)mtSS!JN^rtqKOj}p~> zfpCI@DX*DqcB6ZnBcl~}sGO~1s$AtfkX6fy3N8*ebvZc*KBW;dA=)?#BE&}-or74i zZUt5;{FBPnkZD8YUXDsx&2LvSziAlec3oc>&Lf1Doc3g?H9{OO_$M4B0qTat0UsWP zTlxUeQ3B;oJ%en4n?zQB6*Fb#wH7`$SQN5GI|=DnJKiYm{?-?#-H;#sIjz7kQ4&VW zN9d1(1$_W~S=<%qDD!mwRytas=eqX^iW}YSx3;wJ#)Xp_`Qk1DFiXac$-3;jQbCif zLA-T_s~5yP@Q@W>pXKl^gipQ>gp@HlBB>WDVpW199;V%?N1`U$ovLE;NI2?|_q2~5 zlg>xT9NADWkv5-*FjS~nP^7$k!N2z?dr!)&l0+4xDK7=-6Rkd$+_^`{bVx!5LgC#N z-dv-k@OlYCEvBfcr1*RsNwcV?QT0bm(q-IyJJ$hm2~mq{6zIn!D20k5)fe(+iM6DJ ze-w_*F|c%@)HREgpRrl@W5;_J5vB4c?UW8~%o0)(A4`%-yNk1(H z5CGuzH(uHQ`&j+IRmTOKoJ?#Ct$+1grR|IitpDGt!~ZdqSJ?cOtw-R=EQ+q4UvclH zdX=xlK-fhQKoKCPBoFAZ*(~11O6-tXo>i0w!T$u{lg!#itEUX3V{$S*naW!C@%rll zS{L(1t%xz(*B`{1NL!*aMc<~fE=g;gXi&Gb$HpD!P)8?JzfN;4F&wv(5HH<=c>>)n z({271)xREH89=C(5YKL{mmJJ_d>qHz;;gTvTlgM*vz9@YTTYZ#%_2A zS0G-t9oMQEpvfv(UjfQ8T$vAHi)zOj3>D*{xSRiu3acc=7cvLyD?_ZObdu$5@b*!y zaZ#u?7uF}SrHVQa=sTOhGW{6WUlq#RhPPm^GsRH#qlX8{Kq-i~98l;eq>KdCnWyKl zUu&UWBqu#Tt9jQ97U4}3)&(p2-eCLznXMEm!>i^EMpeVzPg%p;?@O;dJBQQY(vV;d z3v+-3oTPC!2LTUAx^S2t{v;S_h(EZ^0_dS5g^F*m{TEIy^Qal~%mu3h7*o`jWOH}i ztv8M)3X3a*+ry_KkYXYE4dB0?M|t}#Tp+(}6CQ zBbq;xhoHj}b@j-@koDB#XcCY~>_x&Y;i%MH|3tF^X2h{36UCVfQ-;oEA+4ZkJ`^Qi zQf^8}6eFO$Z+Dj-F1wkG##tTx>FjR2oOXFmbKFj6K3+=kePQ<4d7%z5R5cOB;zO6| zm9^m#U4lcA;7t&*=q|a-!`!)}SgYXT#i8hnxtx@kaoBF$QAS-hT7N5kH^l zB^i+})V>L;9_0Qqf-dyF%ky8Mp-dp#%!Nls3vCt}q3QLM3M-(Zs1k}1bqQ9PVU)U` ztE=?;^6=x}_VD%N@${>qhpkU*)AuUBu_cqYiY&@;O$HV*z@~#Tzh?#=CK`=KwBv+o zh%zu%0xPKYtyC)DaQ zpDW}*86g%>BH3IcWMq`g$j()0kWE(qkIL8A&A0mf&+BzxpKF}=`#jG% z&*wa!&pGFLs5_b#QTZE4Bp+})qzyPQ7B4Z7Y*&?0PSX&|FIR;WBP1|coF9ZeP*$9w z!6aJ_3%Sh=HY3FAt8V144|yfu}IAyYHr1OYKIZ51F>_uY^%N#!k~eU53at-_E-Gh?ahmM5y* z+BTIbeH;%v1}Cjo{8d%UeSMWg(nphxEU`sL< zQR~LrTq>Da(FqSP2%&^1ZL#DTo5Sbl9;&57tQ-@U&I#lj)aNSkcfEJwQD!33?anVU z?pw2q7WtMvfji493`rSFnyp7{w87cW`ak=UEYlk5PCB1K6UDVKXyozOChH4yHh~Q< zv>yvKw6WLfi!PZUx60JZcTNM7jo{ww9b8Q+S7C3WA5&llSwdwh$=Q(*(f3ofqcz=nwOmOy z(J!K=*wNoRU*${{Mbwapi9pTB(&VVKefqd-qrUb9*Eyr2E@oZ9Cgf}Mc;QP<0D)R4 zz=!*^VIG4T*7Xl=sJxrWv9hW^eJ%qYp5(d0?E6LZzJ}=7E+1{?GQA;z+!^VBD81}O z0kJ^dKy&WMw+1+aGVYY-v@i28@Gm+sX5=@U%F=Z?W)oar}2~Rc&F|+3A)n-U2GF10+QdxDb^iA@7eL$c7yhBtL z>lABrh^qy9XZ${E1}Ss5!N4;ig0-pUh6@|RPCHOWvgG{|l}2enRgJftsN%D|ck0YO zuAQd2aMPSyGuJ~jm)aY=+p~mGudw4erwE%P^)5f<*$$2C-4^I=e8-}7##ZQ!8!Tep z+Z_!}CAI~sry$|XK$ktXaxP*x<_ijCPp`2=6sNLZU<@9Sz-rz7^BCE9yh0jV4(I!Z zxmA4d;>B-!vD}Xp*&*N%`b^e&R;D97WS}{~{O-EtXeZNfdf51tw!WR6Noo4hjHPv5 z?heYYRSBPjMc}tFEU^|U8a1CxxK%)WTcn9P%`wR^I$QSeMn6=w>Z9OoVvcrl`zYlZ z2y`mAu0bV(Scc>G_EmIo_4 zm*~h`mxYZC&+U>C5G1FZH5L^U>Cq-9UDRQa35jz&NBj*0{uJKfZs5=Fn@&)Xh6aX(H3w9m9BGLePqVotxTeSPh5-mc7$# z-80t6yB0$Nx<54ohdO*QL7m_(&+#*=eoNiYDB4rE4Cag@qfyZS};Fx;Vf1;oync2k z9v#-w?d6R& zOI`CCS_d=tf3|?g3Z}b6-_Rdg3y~enQhmgkni0Cvf9m6%Ft8r;NC5|b%t&?lkl*4{ z8Ui^;Ds^gq6ti(1xB7y_$zA!i-M~#!!tl$ErTR>P~>T=Yky)8(uvPbvLmB=UfoD zrfl}8<1OQrm?8#j1!?s*T>AoectQl&m!o&*^JcIW`_&bk3tN}k^0rjl=HL$z*uIYt z?7l?^Dqr?q1210Sp$xoAy!&{2^{^Anl460 zI&7urrc&|Y{rjv04VOl{y7c82N6xzg5ueYmQ(q(zC3w_C#x*~%yf5j7MI{W`tsoxzA*PrmK)cTskU| zf2C}Bq$>S$-1JgIh0aW@LxI|-8(OGuD#^M01ghh}&#ObO>tZgSw_LW`zdf&IN$YO# z)|X_9m#JwLW5pErZB3ScggKcNzxA9(hyKkK9I#pR&79&*+SV_eu={00{HF=Bb+AEe znaSof+r1jZ!EL5XgqXWkckaFSSyEk}o!%p8XsD}O>borZ6x%X2b&q!s&1-O(>`kZ$ zB2l^5Cx9xQx9)PXN1xPM)@+LxACH_iZ8zGc(>wnFS_O|@hKsxpMjXOzLEa7OvSlM&&G9ioQw9~RsD4F zK7Q+_&|Q6{eZ^8Rx@pKL`le6kH+(fLc{=V&{b%I5=n}VHV4)X_2Y!pYxgC8wU)yP! zPF3t$?(jsC>Ge=&{kmPGUEETpaw(QTAl)m#{qR3_aq9!wK%6XHfV4C>Y^>Z|%ns7j z{Ja?^IA{+@;kR#IjHxkar%3$eJT4?xNBKUVmoO z`A8Zo-{~_;vcikZ(p}EZzU4kO6WPqkMyE{VvS?;44Z@lj zz^fKX9UL!8Wc(9VgI?P4*zpis8dzl};I>yr1>dtXU=FTAlx}Eht4-*7RACL^AflGh zyZb1hTf(~CkMo%#Q%NMgM9tE2D+)joqbtHYA89Ql1nqVTt+MxZ^*FRd&n5YlIi!8m z>$Ysd!l{+C)y;Wa(ZV-=<+NZKV;v4mt}v2m>`v$-$3b;GsLxf= zd~f(rmfpl``{0aVwN7y!>eGyJFP`L+TxHjHTOS{K^$L2`@6(Rli`{EFwpH@R%eZ6g zwf7rc43Yk!=k;{ z-Rn%~B3amGr}}SxfE$vS8FIPL=Qt57$|R#sSoFgdNUT?fYOYjPl%ZBFpi=jq=DWby7Zxm@y;B<89!9= zbgEH*Uy)~iq5kJLX$+ps$kV`#6jW#|9BGz^`ivNeid(wVbk4jl)VBpW&~;eXNi{#` zwx?{DXR~*sqQcFhY0XCfQ4-*2aN1BGX>$_swtKEqnd>j6vcZ!#0)pXRi?<{!P?tGw z2x_`RD$W)qD{?z}VDPt?+)8*rqLWFIPQ(9-VbBdf{7ff?w9CZ{sIi_gnuC$I0(+P8 zms9XB%}VQ>>pve##}jog6+cD?v~n4Pa9Vmc zg#K$|+`adO=B7`uj35Y}6EZ z{dY`x@w8;R-7zrsr1O_~Jvl*|o-x%jF=Rr1C}GXP^|IYN`1sqmG-oI@R#%X66c#5W z$$tQB)sqwiVm;Y^`Dw3mo|firP{*HsOQJre5%Dm^H@we0FN88VWJ0dja?_U38z73f zrCV!b3qNP0kM#%9T!W5`ynGcg%BL28FW1J-J1_S`BJGCaReQ!am(2%qZ3lLgzq|ns z!!fF@`0=*z)J2BwZ*hO|Yu^cI_nF$9l-Pb3jE7=P8gZ#!xiuZ7-cSa`gb`6mxGTgg z-DLdID?M!Z%+hHB#{?&0$GFRpf+_}q<_wbzX6K?w;%6szz1RbySDSr2r^h_qi$khs zXdZ9A0!_Bf)TR2-^-K~q`FQ!#1x(U4VbV%AA@Ei{%cA(EwC{XfjRi?`&9rav5;Q5% zO1`Rn@OA_ZB@N*mC#)?d3P!}Eh;=NgpIKsy{(yr`hv=aouwt@r&P&}Z3DNWo9ro30 zX52~(aTV$*HHlgB66-4GQru!_AZ|)V*I5X=WG)`N@U&D>e@@C#V@JwEL*L`7#$yes z62C^5%Qniaow2$3HrAc7U{qzpb&FA*xLI1JSWR@`RF=JCcvTI)%dH7;sWInt9JLu# z|Ao|Q?K)cDg_JKsym=joo5gR80wtv01N`um1nQ@Ms0Y*bVzxL34} zo?gizp?`=Y{*W>^Hy2%Jl)y?A+&7s1UVHFixuIy~sawXjcDCL`129cK7|ZQS0u;A} zTJC#WNmqkIrnHpAhHVcM(U^vJA~dl@jf_bs*3?i+=&vuC?Aiy_pcB~=1syDni4 zw+FLuz>F773u#$;NUQ9WDtUPY@+rA3WBhQdKFKOyzkA(URa7;4tW>3jQIfi8v0h3g zJC_HVDXS#>DWb|&se7FHnr=q&l#xg9o02}}u=b-R>@sw={Z zHF*?t2FmhqZ=|qa>x=A!*$S+0T zhO*D*M?NTf-eX`eO)9TIQu{7Dm77Acnj4b1jI9@c*ZL8wL%8kLEhd$KM8=Y!fbN@9 zC7B5#y>JM1n5M)!&im==EgHs2j+xCZG~+~QWCi?s!QyFo2kqx{%jE2n3^N*Ayz6Lp zhg5g^3# z+5FoJ@$u@9WJgPKpUWEd4}4AK9TJKU8W%ms!d0p%OIOX+bY+55zl!vIaz$XFI9Ep+ z;bL_}7PDI2Y`Ng*XY(65 zh0%`@Lve%fc;)N4_g12bNrt6gH=N#OHtxO`$lpWlw=Z6MF+E@;>GkZ#lAZTn`aHwf z&I1|aV#b_VHMIgBN*RzU9i@Z@m}0i>o?({&%fpEfaOpFeaJ7V37;m0?kzd}}Lk@9$ zL}8TEo7WZAcRi%zFZxkr6<0k#X-;lTD`Oc~cDb@olwgWCewvk{GJ}hCXbF!AdiLpd z|Cck$ZTKI?Ack{34Lva7+k=H8K2HTZiurox6F+>dy+@R9T^awxj590D$|kXUg+Ygc z(f)jlRwN(4z$#%PnOVc;#Fv{nAi{#UcXPNcmP#5O{zh_*`=q^JCeia{sN4zHjk2*y zqUVh{Ya{j>SPmP^i#Qfcq_MTqo8g52Fi^F zKBc$$HVI!xFx*4Y9l+nt)$AoZORD}%5I10oI3kx`-N30QueiwIw#0VV2E*Fb-nKW% z=+r^hos`Y-7~{cA1FVbK$_=~*z53+Q8KGjg;>ztg((H12%QTf4OYU8y)C}h5yo#$% z&Q$`vMM*g?ZcatAn2j!hFv8KuN(dw)T*}sF#THDHxo8xC^?vJ zc`U6bVo~hOr6I!8*GTZ<^D~;unKjK=!IR|GB4E>Mcvt*2GK);93jIDd<(nNjHO z4Hi@2^%Uyx=^Z~5eZ!5rO5%4H|eFoNjD#+Kcu%_57zZb4Z@Ak#X6txD^{U3wBl^r+W- zLorkK;uc;NgTj7dGxHQS+@T*T>Q*j4^Ll$ejQqWrwcHyG9y%Mk%m8nBVG5hvSaYm5 zJN^#-Q46kZG)@T8n2^QCjxIwxUVi%s>EY`E?#@_(A~njFrTiDq;8v|W-1jT|ROlNI zU$h|YoD4PVTE^&NC6_m{EAFBVqsM`P*`-AcDGWQygURzM32Xeq2xng~XQsYeTZ5v$ zQLaa2M_Iplw}4eL6fLPu`6`PYcVMysO>`{8CB~glD=TX7?JZcHfHNmykBM?QD)#D) zGp>R*<^D?WhFQKRc^}22l6F=D2RPrxaX2ZF!b1X0XF*d4%=!sbNcS1q2WOUE(7e4$ z^L8f;F)__d3>&KQFE8%$I4h^y5FYBfB&fWzn71_OSrPe-DHV{O#Q;GP z+Tw!J?eVjX19RKH?*hKQWQt8r7B#lYX8xoSHFGCW-*DSQ4EM4M3Mw%gkSYNK18@(e zfzMF}WWaCyS@1y%-~Xg0ry~tkQkUmKuI5lGAua{{vn22V!2T()AU5FpKh@Nv)s^Js zv~@VuUG;=CnLmQR{PeUBQf2;lAV!vG>^Z0N zL88rrjL-*J!43;7C=w9xhcw`yjRKq7o4L9=0SmR9PA-nX12@#h(iIu-0N_xm2OV)( zU_raT0y>$wm^oMi2|U3N;OhF9uy}`<-xVka#DV*l{O0yHzi9vUxa1Qtpi$buR*8cU zd4~lS1pT$L^!0=6qUKOpM+XPsy{f7W#1bjrEwaeN!Ik9(zySIT^pEHvHgJUneFN4) zk=k|$55(g8slmS|@+*4fr2urd3LwjIIZA**g+%l(SZNn4HwQ}y6o`vw>2&mR1X+&q zDa1Af0B;4rAMZMOlHbAqK|R_xuwJ7ANARtFE({-P2o{tJJR<>2KVp)ZK-M;)ejx zd*E~Mka<{OL7%CAhk4n|1qg?97-I!l0rOinjVi#arbgg4bi5;nY5oFL`UWtPk5&L#grSxv zE3!}=1px!ZTLT90aYc^s`~{VojjJml&<`@e41dFP+XU6D0AOkbn2rlI3>^LcqauG& zc$m3Z{!u8LvUrm^fT{qX5yD9{?r(CCiUdck%!T`KIZd2oQJz1joB&M(Teg_>;yS<2-5>BWfSPpG`Rt{!j6>kqMAvl^zk0JUEfy$HVJMkxP-GkwZuxL62me2#pj_5*ZIU zP~#C^OZLfl$HO)v;~~c&JHivn|1I9H5y_CDkt0JLLGKm(4*KLVhJ2jh2#vJuM6`b& zE==-lvME^Oj022xF&IV*? '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod/adapters/forge-1.8.9/gradlew.bat b/mod/adapters/forge-1.8.9/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod/adapters/forge-1.8.9/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java new file mode 100644 index 0000000..94ea8d0 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaBlock.java @@ -0,0 +1,251 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotSettings; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; +import net.minecraft.block.properties.PropertyDirection; +import net.minecraft.block.state.BlockState; +import net.minecraft.block.state.IBlockState; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityLivingBase; +import net.minecraft.util.AxisAlignedBB; +import net.minecraft.util.BlockPos; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.EnumWorldBlockLayer; +import net.minecraft.world.Explosion; +import net.minecraft.world.IBlockAccess; +import net.minecraft.world.World; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** + * A placeholder block whose behaviour comes from a {@link SlotSettings} object rather + * than from constructor arguments. + * + *

This inversion is the whole trick. A stock block bakes hardness, render layer and + * shape in at construction, so changing any of them means registering a new block — which + * frozen registries forbid. Reading each property on demand means the launcher can change + * them by writing to a field, and the next query picks it up. + * + *

Three differences from the 1.12.2 adapter, all forced by the older API and all + * verified against the decompiled 1.8.9 sources rather than assumed: + * + *

    + *
  • {@code isOpaqueCube()} and {@code isFullCube()} take no block state. On 1.8.9 they + * are properties of the block itself, so a slot cannot vary them per state — which + * costs nothing here, since every Ella slot has exactly one meaningful appearance. + *
  • There is no {@code getBoundingBox}. Bounds are mutable fields set through + * {@link #setBlockBoundsBasedOnState}, which the game calls before it reads them. + *
  • Sound is a public field rather than a getter, so it is assigned rather than + * overridden — see {@link #applySound()}. + *
+ */ +public class EllaBlock extends Block { + + /** + * Stand-in used while {@link Block}'s constructor is still running. + * + *

{@code Block.} builds its default block state, and that calls overridable + * methods such as {@link #isOpaqueCube} before this class's fields are + * assigned — so {@link #settings} is genuinely null for the duration of {@code super()}. + * Reading it directly there throws, and the stack trace points at the override rather + * than at the constructor that provoked it. + * + *

Shared and never mutated: it only ever supplies defaults during construction. + */ + private static final SlotSettings DEFAULTS = new SlotSettings(); + + private final SlotSettings settings; + private final int slot; + + /** + * Orientation, present on every slot. + * + *

State properties are baked into the {@link BlockState} at construction, exactly + * like registry entries are frozen after startup. A slot that might later be made + * rotatable therefore has to carry the property from the start; the {@code rotation} + * setting only decides whether placement uses it. + */ + public static final PropertyDirection FACING = PropertyDirection.create("facing"); + + public EllaBlock(SlotSettings settings, int slot) { + // Material.rock is only a starting point; the properties that matter are all + // overridden below to read from `settings`. + super(Material.rock); + this.settings = settings; + this.slot = slot; + + setHardness(1.5f); + setResistance(6.0f); + setDefaultState(this.blockState.getBaseState().withProperty(FACING, EnumFacing.NORTH)); + } + + // --- state --------------------------------------------------------------- + + @Override + protected BlockState createBlockState() { + return new BlockState(this, FACING); + } + + @Override + public IBlockState getStateFromMeta(int meta) { + // Six directions fit in three bits; anything else is a corrupt value, so fall + // back to north rather than throwing during world load. + EnumFacing facing = EnumFacing.getFront(meta); + return getDefaultState().withProperty(FACING, facing); + } + + @Override + public int getMetaFromState(IBlockState state) { + return state.getValue(FACING).getIndex(); + } + + /** + * Chooses the facing a newly placed block gets. + * + *

1.8.9's hook is {@code onBlockPlaced} rather than the {@code getStateForPlacement} + * of later versions. With rotation off this always returns north, so the block renders + * exactly as authored — the property exists but is never varied. + */ + @Override + public IBlockState onBlockPlaced(World world, BlockPos pos, EnumFacing face, + float hitX, float hitY, float hitZ, int meta, + EntityLivingBase placer) { + String mode = live().rotation; + + if ("horizontal".equals(mode)) { + // Facing the player, which is what furnaces and chests do. + return getDefaultState().withProperty(FACING, placer.getHorizontalFacing().getOpposite()); + } + if ("all".equals(mode)) { + // The face that was clicked, like pistons. + return getDefaultState().withProperty(FACING, face); + } + return getDefaultState().withProperty(FACING, EnumFacing.NORTH); + } + + /** + * The live settings, or shared defaults while the superclass constructor is running. + * Every override below goes through this rather than touching the field. + */ + private SlotSettings live() { + SlotSettings current = settings; + return current != null ? current : DEFAULTS; + } + + public int slot() { + return slot; + } + + public SlotSettings settings() { + return settings; + } + + /** + * Copies the slot's sound onto the block. + * + *

1.8.9 has no {@code getSoundType} hook — {@code stepSound} is a plain public + * field the game reads directly, so keeping it current means assigning it whenever the + * setting changes. Called from the host on every settings patch. + */ + void applySound() { + this.stepSound = Sounds.byName(live().soundType); + } + + // --- appearance --------------------------------------------------------- + + /** 1.8.9 names both the method and the enum differently from later versions. */ + @Override + @SideOnly(Side.CLIENT) + public EnumWorldBlockLayer getBlockLayer() { + String layer = live().renderLayer; + if ("cutout".equals(layer)) return EnumWorldBlockLayer.CUTOUT; + if ("cutout_mipped".equals(layer)) return EnumWorldBlockLayer.CUTOUT_MIPPED; + if ("translucent".equals(layer)) return EnumWorldBlockLayer.TRANSLUCENT; + return EnumWorldBlockLayer.SOLID; + } + + /** + * Controls whether neighbouring faces are culled. Returning false is what actually + * makes a transparent block look transparent — the render layer alone is not enough, + * which is the mistake the editor warns about. + */ + @Override + public boolean isOpaqueCube() { + return live().opaque; + } + + @Override + public boolean isFullCube() { + return live().fullCube; + } + + @Override + public int getLightValue(IBlockAccess world, BlockPos pos) { + return live().lightLevel; + } + + @Override + public int getLightOpacity(IBlockAccess world, BlockPos pos) { + // A non-opaque block must not block light, otherwise glass-like models render + // with a black interior. + return live().opaque ? 255 : 0; + } + + // --- physical ----------------------------------------------------------- + + @Override + public float getBlockHardness(World world, BlockPos pos) { + return live().hardness; + } + + @Override + public float getExplosionResistance(World world, BlockPos pos, Entity exploder, + Explosion explosion) { + // Minecraft stores resistance pre-divided by 5; the launcher exposes the value + // players actually recognise, so convert here rather than in the editor. + return live().resistance / 5.0f; + } + + // --- shape -------------------------------------------------------------- + + /** + * 1.8.9 reads a block's shape from mutable fields rather than from a returned box, and + * calls this first so the block can set them for the state at hand. Everything that + * asks about our bounds therefore goes through here. + */ + @Override + public void setBlockBoundsBasedOnState(IBlockAccess world, BlockPos pos) { + applyBounds(); + } + + @Override + public void setBlockBoundsForItemRender() { + applyBounds(); + } + + private void applyBounds() { + if (!"custom".equals(live().collision)) { + setBlockBounds(0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f); + return; + } + float[] box = live().hitboxNormalised(); + setBlockBounds(box[0], box[1], box[2], box[3], box[4], box[5]); + } + + @Override + public AxisAlignedBB getCollisionBoundingBox(World world, BlockPos pos, IBlockState state) { + // Null is 1.8.9's "no collision at all", the equivalent of the later NULL_AABB. + if ("none".equals(live().collision)) return null; + applyBounds(); + return super.getCollisionBoundingBox(world, pos, state); + } + + @Override + public boolean shouldSideBeRendered(IBlockAccess world, BlockPos pos, EnumFacing side) { + // Let two adjacent non-opaque blocks of the same kind still draw their shared + // faces; hiding them is only correct for full opaque cubes. + if (!live().opaque || !live().fullCube) return true; + return super.shouldSideBeRendered(world, pos, side); + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java new file mode 100644 index 0000000..8822350 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItem.java @@ -0,0 +1,82 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotSettings; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.EnumRarity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import java.util.List; + +/** + * A placeholder item, reading its properties from a live {@link SlotSettings} for the + * same reason {@link EllaBlock} does. + */ +public class EllaItem extends Item { + + /** Same guard as EllaBlock: protects reads during superclass construction. */ + private static final SlotSettings DEFAULTS = new SlotSettings(); + + private final SlotSettings settings; + private final int slot; + + public EllaItem(SlotSettings settings, int slot) { + this.settings = settings; + this.slot = slot; + // The real limit is enforced by getItemStackLimit below; this only sets a + // starting point for code that reads the field directly. + setMaxStackSize(64); + } + + private SlotSettings live() { + SlotSettings current = settings; + return current != null ? current : DEFAULTS; + } + + public int slot() { + return slot; + } + + public SlotSettings settings() { + return settings; + } + + @Override + public int getItemStackLimit(ItemStack stack) { + return live().stackSize; + } + + @Override + public EnumRarity getRarity(ItemStack stack) { + String rarity = live().rarity; + if ("uncommon".equals(rarity)) return EnumRarity.UNCOMMON; + if ("rare".equals(rarity)) return EnumRarity.RARE; + if ("epic".equals(rarity)) return EnumRarity.EPIC; + return EnumRarity.COMMON; + } + + @Override + public boolean hasEffect(ItemStack stack) { + return live().glint || super.hasEffect(stack); + } + + /** + * Bound slots only — see {@link EllaItemBlock} for why. + * + *

1.8.9 passes the item and a plain {@code List}; the {@code NonNullList} of later + * versions does not exist yet. + */ + @Override + @SideOnly(Side.CLIENT) + public void getSubItems(Item item, CreativeTabs tab, List items) { + if (live().isBound()) items.add(new ItemStack(this)); + } + + @Override + public String getItemStackDisplayName(ItemStack stack) { + String name = live().displayName; + return name == null || name.isEmpty() ? super.getItemStackDisplayName(stack) : name; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java new file mode 100644 index 0000000..20abad2 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaItemBlock.java @@ -0,0 +1,57 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotSettings; +import net.minecraft.block.Block; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.Item; +import net.minecraft.item.ItemBlock; +import net.minecraft.item.ItemStack; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +import java.util.List; + +/** + * The item form of a placeholder block. + * + *

Exists to keep unbound slots out of the creative tab. The pool holds 128 blocks so + * that binding one needs no restart, but showing all 128 fills the tab with identical + * untextured cubes — and picking one of those is indistinguishable from a bug in the + * block you were actually working on. + * + *

The constructor takes a plain {@link Block} rather than an {@link EllaBlock} because + * {@code GameRegistry.registerBlock} instantiates the item class reflectively, looking for + * exactly that signature. + */ +public class EllaItemBlock extends ItemBlock { + + private final SlotSettings settings; + + public EllaItemBlock(Block block) { + super(block); + this.settings = block instanceof EllaBlock ? ((EllaBlock) block).settings() : null; + } + + @Override + @SideOnly(Side.CLIENT) + public void getSubItems(Item item, CreativeTabs tab, List items) { + // Bound slots only. An unbound one has no model and no name to show. + if (settings != null && settings.isBound()) { + items.add(new ItemStack(this)); + } + } + + /** + * The name shown in the tab and hotbar. + * + *

Read from the slot rather than a translation key: the launcher already knows the + * display name and pushes it over the protocol, so it is live and needs no resource + * reload. The translation key remains as the fallback for a slot bound before the + * name arrives. + */ + @Override + public String getItemStackDisplayName(ItemStack stack) { + String name = settings == null ? null : settings.displayName; + return name == null || name.isEmpty() ? super.getItemStackDisplayName(stack) : name; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java new file mode 100644 index 0000000..591051b --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaLogBridge.java @@ -0,0 +1,44 @@ +package dev.ella.forge189; + +import dev.ella.core.EllaLog; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Routes {@link EllaLog} through Forge's Log4j logger, so Ella's output lands in the + * game log alongside everything else rather than on bare stderr. + */ +final class EllaLogBridge { + + private static final Logger LOGGER = LogManager.getLogger("Ella"); + + private EllaLogBridge() { + } + + static void install() { + EllaLog.setSink(new EllaLog.Sink() { + @Override + public void log(EllaLog.Level level, String message, Throwable error) { + switch (level) { + case DEBUG: + LOGGER.debug(message, error); + break; + case WARN: + LOGGER.warn(message, error); + break; + case ERROR: + LOGGER.error(message, error); + break; + case INFO: + default: + LOGGER.info(message, error); + break; + } + } + }); + } + + static void info(String message) { + LOGGER.info(message); + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java new file mode 100644 index 0000000..479bbc4 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaMod.java @@ -0,0 +1,107 @@ +package dev.ella.forge189; + +import dev.ella.core.EllaCore; +import dev.ella.core.EllaLog; +import dev.ella.core.SlotPool; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; +import net.minecraftforge.fml.common.event.FMLServerStoppingEvent; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** + * Ella's 1.8.9 entry point. + * + *

Client-side only: Ella is a modelling tool, and everything it does — resource packs, + * render layers, live reloads — is a client concern. Marking it so keeps it from being + * demanded of servers. + * + *

Registration happens inline here rather than through the registry events of later + * versions, which 1.8.9 does not have. + */ +/* + * `acceptedMinecraftVersions` must match this adapter's entry in ADAPTERS + * (launcher/src/shared/version.ts), and a launcher test fails if the two drift. + * + * Without it FML derives the range from `mcversion` in mcmod.info, which yields the exact + * version and nothing else — the mod then refuses to load on 1.8.8 with "Ella (ella) wants + * Minecraft [1.8.9,1.8.9]" while the launcher cheerfully offered 1.8.8 as live-editable. + * The mappings say one jar serves both versions; this is where that has to be said out + * loud to the loader. + */ +@Mod( + modid = EllaMod.MOD_ID, + name = "Ella", + version = EllaMod.VERSION, + clientSideOnly = true, + acceptedMinecraftVersions = "[1.8.8,1.9)", + acceptableRemoteVersions = "*" +) +public final class EllaMod { + + public static final String MOD_ID = "ella"; + public static final String VERSION = "0.2.0"; + + /** Default pool size, overridable with {@code -Della.slots.block} / {@code .item}. */ + private static final int DEFAULT_SLOTS = 128; + + private static SlotPool pool; + private static EllaCore core; + + public static SlotPool pool() { + return pool; + } + + @Mod.EventHandler + public void preInit(FMLPreInitializationEvent event) { + EllaLogBridge.install(); + + int blocks = slotCount("ella.slots.block"); + int items = slotCount("ella.slots.item"); + pool = new SlotPool(blocks, items); + + EllaLog.info("Ella " + VERSION + " starting with " + blocks + " block and " + + items + " item slots"); + + Registration.registerAll(pool); + registerClientModels(); + } + + /** + * Split out and side-guarded so the class loader never touches the client-only model + * API on a server. Called from preInit because 1.8.9 bakes models before init. + */ + @SideOnly(Side.CLIENT) + private static void registerClientModels() { + Registration.registerModels(); + } + + @Mod.EventHandler + public void init(FMLInitializationEvent event) { + // Started after registration so the pool is fully populated before the launcher + // can bind anything to it. + core = EllaCore.fromSystemProperties(new ForgeHost(pool), pool); + if (core != null) core.start(); + } + + @Mod.EventHandler + public void serverStopping(FMLServerStoppingEvent event) { + // Slots hold references to world state indirectly; clearing on world unload + // avoids a stale binding pointing at a world that no longer exists. + if (pool != null) pool.clearAll(); + } + + private static int slotCount(String property) { + String configured = System.getProperty(property); + if (configured == null) return DEFAULT_SLOTS; + + try { + // Bounds match the launcher's own clamp, so the two cannot disagree. + return Math.max(16, Math.min(1024, Integer.parseInt(configured))); + } catch (NumberFormatException malformed) { + EllaLog.warn("Ignoring malformed " + property + ": " + configured); + return DEFAULT_SLOTS; + } + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java new file mode 100644 index 0000000..02d9632 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/EllaResources.java @@ -0,0 +1,98 @@ +package dev.ella.forge189; + +import dev.ella.core.EllaLog; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.FolderResourcePack; +import net.minecraft.client.resources.IResourcePack; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.List; + +/** + * Makes the launcher's workspace directory part of the client's resource stack. + * + *

{@code Minecraft.defaultResourcePacks} is private with no accessor, so this goes + * through reflection. An access transformer would be tidier, but ATs are configured + * per-loader and would have to be rewritten for each adapter; reflection keeps the trick + * contained in one file per version. + * + *

Two field names are tried because a Forge mod runs against MCP names in a development + * workspace and SRG names in production, and the same jar has to work in both. The SRG + * name happens to be identical to 1.12.2's here, which is luck rather than a rule — it was + * read out of the 1.8.9 mappings, not carried over. + */ +final class EllaResources { + + /** MCP name (development) and SRG name (production) for defaultResourcePacks. */ + private static final String[] FIELD_NAMES = { "defaultResourcePacks", "field_110449_ao" }; + + private static File injected; + + private EllaResources() { + } + + /** + * Adds {@code packRoot} to the resource stack if it is not already there. + * + * @return true when the pack is in place + */ + static synchronized boolean inject(File packRoot) { + if (packRoot == null || !packRoot.isDirectory()) { + EllaLog.warn("Workspace pack directory does not exist: " + packRoot); + return false; + } + + // Re-injecting the same directory on every reconnect would stack duplicate packs. + if (packRoot.equals(injected)) return true; + + List packs = resourcePackList(); + if (packs == null) return false; + + try { + packs.add(new FolderResourcePack(packRoot) { + @Override + public String getPackName() { + return "Ella workspace"; + } + }); + injected = packRoot; + EllaLog.info("Injected workspace resource pack: " + packRoot.getAbsolutePath()); + return true; + } catch (RuntimeException failed) { + EllaLog.error("Could not add the workspace resource pack", failed); + return false; + } + } + + @SuppressWarnings("unchecked") + private static List resourcePackList() { + Minecraft client = Minecraft.getMinecraft(); + + for (String name : FIELD_NAMES) { + try { + Field field = Minecraft.class.getDeclaredField(name); + field.setAccessible(true); + Object value = field.get(client); + if (value instanceof List) { + return (List) value; + } + } catch (NoSuchFieldException wrongName) { + // Expected for whichever of the two names does not apply here. + } catch (IllegalAccessException blocked) { + EllaLog.error("Access to Minecraft." + name + " was denied", blocked); + return null; + } + } + + EllaLog.error( + "Could not find Minecraft's resource pack list under any known field name; " + + "live model loading is unavailable on this build.", null); + return null; + } + + /** Whether a workspace pack is currently injected. */ + static boolean isInjected() { + return injected != null; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java new file mode 100644 index 0000000..dcb483a --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/ForgeHost.java @@ -0,0 +1,279 @@ +package dev.ella.forge189; + +import dev.ella.core.Capabilities; +import dev.ella.core.EllaHost; +import dev.ella.core.EllaLog; +import dev.ella.core.SlotPool; +import dev.ella.core.SlotSettings; +import net.minecraft.block.Block; +import net.minecraft.client.Minecraft; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.BlockPos; +import net.minecraft.util.MathHelper; +import net.minecraft.world.World; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The 1.8.9 implementation of {@link EllaHost}. + * + *

Every method here can be called from the IPC thread, so anything touching the game + * is handed to the client or server scheduler. Doing the work inline would be a data race + * against the render and tick loops — the kind that produces crashes far from the cause. + */ +public final class ForgeHost implements EllaHost { + + private static final Set CAPABILITIES = new HashSet(Arrays.asList( + Capabilities.RENDER_LAYER_CUTOUT, + Capabilities.RENDER_LAYER_CUTOUT_MIPPED, + Capabilities.RENDER_LAYER_TRANSLUCENT, + Capabilities.RENDER_LAYER_RUNTIME, + Capabilities.LIGHT_DYNAMIC, + Capabilities.HITBOX_CUSTOM, + Capabilities.HITBOX_RUNTIME, + Capabilities.RELOAD_PROGRAMMATIC, + Capabilities.ENTRY_PLACE, + Capabilities.BLOCK_ROTATION + // Same omissions as 1.12.2: ITEM_RARITY predates the launcher's enum, MODEL_OBJ + // needs the Forge OBJ loader — which 1.8.9 does not have at all — and + // ITEM_COMPONENTS only exists from 1.20.5. + )); + + private final SlotPool pool; + + public ForgeHost(SlotPool pool) { + this.pool = pool; + } + + // --- identity ----------------------------------------------------------- + + @Override + public String minecraftVersion() { + return "1.8.9"; + } + + @Override + public String loaderName() { + return "forge"; + } + + @Override + public String loaderVersion() { + return net.minecraftforge.common.ForgeVersion.getVersion(); + } + + @Override + public String adapterId() { + return "forge-1.8.9"; + } + + @Override + public String adapterVersion() { + return EllaMod.VERSION; + } + + @Override + public int packFormat() { + // 1.8 and 1.8.9 use pack_format 1. Must match src/main/resources/pack.mcmeta, + // which Forge reads from the mod jar itself. + return 1; + } + + @Override + public Set capabilities() { + return CAPABILITIES; + } + + @Override + public int slotCount(String kind) { + return pool.size(kind); + } + + // --- slots -------------------------------------------------------------- + + @Override + public void onSlotAssigned(String kind, int slot, SlotSettings settings) { + if (!SlotPool.BLOCK.equals(kind)) return; + + // Sound is a field on this version rather than a getter, so it has to be pushed + // rather than pulled — see EllaBlock.applySound. + EllaBlock block = Registration.block(slot); + if (block != null) block.applySound(); + + scheduleClient(new Runnable() { + @Override + public void run() { + refreshWorldRenderers(); + } + }); + } + + @Override + public void onSlotCleared(String kind, int slot) { + onSlotAssigned(kind, slot, pool.get(kind, slot)); + } + + @Override + public List onSettingsPatched(String kind, int slot, List requested, + List applied) { + boolean needsRerender = false; + for (String key : applied) { + if ("soundType".equals(key) && SlotPool.BLOCK.equals(kind)) { + EllaBlock block = Registration.block(slot); + if (block != null) block.applySound(); + } + if ("renderLayer".equals(key) || "opaque".equals(key) || "fullCube".equals(key) + || "lightLevel".equals(key) || "emissive".equals(key)) { + needsRerender = true; + } + } + + if (needsRerender) { + scheduleClient(new Runnable() { + @Override + public void run() { + refreshWorldRenderers(); + } + }); + } + + // Every key core managed to apply is genuinely honoured on this version, except + // the ones whose capability is not declared — the launcher greys those out, so + // reaching here with one means the project targets a newer version. + List honoured = new ArrayList(); + for (String key : applied) { + if ("rarity".equals(key) && !CAPABILITIES.contains(Capabilities.ITEM_RARITY)) { + continue; + } + honoured.add(key); + } + return honoured; + } + + // --- resources ---------------------------------------------------------- + + @Override + public void injectResourcePack(final File packRoot) { + scheduleClient(new Runnable() { + @Override + public void run() { + if (EllaResources.inject(packRoot)) { + Minecraft.getMinecraft().refreshResources(); + } + } + }); + } + + @Override + public void reloadResources() { + scheduleClient(new Runnable() { + @Override + public void run() { + // The programmatic equivalent of F3+T. + Minecraft.getMinecraft().refreshResources(); + refreshWorldRenderers(); + } + }); + } + + /** Forces chunk meshes to rebuild so shape and render-layer changes become visible. */ + private static void refreshWorldRenderers() { + Minecraft client = Minecraft.getMinecraft(); + if (client.renderGlobal != null) { + client.renderGlobal.loadRenderers(); + } + } + + // --- player actions ----------------------------------------------------- + + @Override + public void giveToPlayer(final String kind, final int slot, final int count) { + final MinecraftServer server = requireServer(); + + server.addScheduledTask(new Runnable() { + @Override + public void run() { + EntityPlayerMP player = firstPlayer(server); + if (player == null) return; + + ItemStack stack = stackFor(kind, slot, count); + if (stack == null) { + EllaLog.warn("No registered " + kind + " for slot " + slot); + return; + } + + // Going through the server player rather than the client inventory is + // what makes the item actually persist rather than vanish next tick. + if (!player.inventory.addItemStackToInventory(stack)) { + // 1.8.9's three-argument form: the extra flag is "trace to the player", + // which keeps a dropped item from landing behind them. + player.dropItem(stack, false, false); + } + } + }); + } + + @Override + public void placeInFrontOfPlayer(final String kind, final int slot) { + if (!SlotPool.BLOCK.equals(kind)) { + throw new IllegalStateException("Only blocks can be placed"); + } + + final MinecraftServer server = requireServer(); + + server.addScheduledTask(new Runnable() { + @Override + public void run() { + EntityPlayerMP player = firstPlayer(server); + if (player == null) return; + + Block block = Registration.block(slot); + if (block == null) return; + + // Two blocks ahead at eye level, which lands in view without landing + // inside the player. + double yaw = Math.toRadians(player.rotationYaw); + int x = MathHelper.floor_double(player.posX - Math.sin(yaw) * 2.0); + int z = MathHelper.floor_double(player.posZ + Math.cos(yaw) * 2.0); + int y = MathHelper.floor_double(player.posY); + + World world = player.worldObj; + BlockPos position = new BlockPos(x, y, z); + world.setBlockState(position, block.getDefaultState(), 3); + } + }); + } + + private ItemStack stackFor(String kind, int slot, int count) { + Item item = SlotPool.BLOCK.equals(kind) + ? Registration.blockItem(slot) + : Registration.item(slot); + return item == null ? null : new ItemStack(item, Math.max(1, count)); + } + + private static EntityPlayerMP firstPlayer(MinecraftServer server) { + List players = server.getConfigurationManager().playerEntityList; + return players.isEmpty() ? null : players.get(0); + } + + /** @throws IllegalStateException when no world is loaded, which core maps to NOT_IN_WORLD */ + private static MinecraftServer requireServer() { + MinecraftServer server = Minecraft.getMinecraft().getIntegratedServer(); + if (server == null) { + throw new IllegalStateException("No world is loaded"); + } + return server; + } + + private static void scheduleClient(Runnable task) { + Minecraft.getMinecraft().addScheduledTask(task); + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java new file mode 100644 index 0000000..ccd57d0 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Registration.java @@ -0,0 +1,127 @@ +package dev.ella.forge189; + +import dev.ella.core.SlotPool; +import net.minecraft.block.Block; +import net.minecraft.client.resources.model.ModelResourceLocation; +import net.minecraft.creativetab.CreativeTabs; +import net.minecraft.item.Item; +import net.minecraft.item.ItemBlock; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.client.model.ModelLoader; +import net.minecraftforge.fml.common.registry.GameRegistry; + +import java.util.ArrayList; +import java.util.List; + +/** + * Registers the slot pool during mod startup. + * + *

Everything here happens while registries are still open. Nothing is registered later, + * because nothing can be: binding a project entry to a slot at runtime only changes the + * settings object a placeholder already reads from. + * + *

1.8.9 has neither the registry events nor the {@code @Mod.EventBusSubscriber} of + * later versions, so this is called directly from preInit rather than driven by the event + * bus. {@code GameRegistry.registerBlock} builds the item form reflectively, which is why + * {@link EllaItemBlock} takes a plain {@code Block}. + */ +public final class Registration { + + private static final List BLOCKS = new ArrayList(); + private static final List ITEMS = new ArrayList(); + private static final List BLOCK_ITEMS = new ArrayList(); + + /** + * Creative tab so the placeholders can be found without the launcher running. + * + *

1.8.9's tab supplies an {@link Item} rather than an {@link net.minecraft.item.ItemStack}. + */ + public static final CreativeTabs TAB = new CreativeTabs(EllaMod.MOD_ID) { + @Override + public Item getTabIconItem() { + return BLOCK_ITEMS.isEmpty() + ? Item.getItemFromBlock(net.minecraft.init.Blocks.stone) + : BLOCK_ITEMS.get(0); + } + }; + + private Registration() { + } + + public static List blocks() { + return BLOCKS; + } + + public static List items() { + return ITEMS; + } + + /** Called from preInit, before FML freezes the registries. */ + static void registerAll(SlotPool pool) { + for (int slot = 0; slot < pool.size(SlotPool.BLOCK); slot++) { + EllaBlock block = new EllaBlock(pool.get(SlotPool.BLOCK, slot), slot); + String path = SlotPool.registryPath(SlotPool.BLOCK, slot); + + block.setUnlocalizedName(EllaMod.MOD_ID + "." + path); + block.setCreativeTab(TAB); + block.applySound(); + + GameRegistry.registerBlock(block, EllaItemBlock.class, path); + + BLOCKS.add(block); + // Fetched back rather than constructed: the registry made the instance, so + // this is the only reference that is certainly the registered one. + Item itemForm = Item.getItemFromBlock(block); + if (itemForm instanceof ItemBlock) BLOCK_ITEMS.add((ItemBlock) itemForm); + } + + for (int slot = 0; slot < pool.size(SlotPool.ITEM); slot++) { + EllaItem item = new EllaItem(pool.get(SlotPool.ITEM, slot), slot); + String path = SlotPool.registryPath(SlotPool.ITEM, slot); + + item.setUnlocalizedName(EllaMod.MOD_ID + "." + path); + item.setCreativeTab(TAB); + + GameRegistry.registerItem(item, path); + ITEMS.add(item); + } + + EllaLogBridge.info("Registered " + BLOCKS.size() + " block and " + ITEMS.size() + + " item slots"); + } + + /** + * Points every placeholder at its inventory model. + * + *

Called from client preInit: 1.8.9 has no model registry event, and the mapping + * has to be in place before the model system bakes. The model files themselves come + * from the launcher's workspace pack, which is why the pack must be injected before + * the first resource load rather than after. + */ + static void registerModels() { + for (int slot = 0; slot < BLOCK_ITEMS.size(); slot++) { + bindModel(BLOCK_ITEMS.get(slot), SlotPool.registryPath(SlotPool.BLOCK, slot)); + } + for (int slot = 0; slot < ITEMS.size(); slot++) { + bindModel(ITEMS.get(slot), SlotPool.registryPath(SlotPool.ITEM, slot)); + } + } + + private static void bindModel(Item item, String path) { + ModelLoader.setCustomModelResourceLocation( + item, 0, + new ModelResourceLocation(new ResourceLocation(EllaMod.MOD_ID, path), "inventory")); + } + + public static ItemBlock blockItem(int slot) { + return slot >= 0 && slot < BLOCK_ITEMS.size() ? BLOCK_ITEMS.get(slot) : null; + } + + public static EllaBlock block(int slot) { + return slot >= 0 && slot < BLOCKS.size() ? BLOCKS.get(slot) : null; + } + + public static EllaItem item(int slot) { + return slot >= 0 && slot < ITEMS.size() ? ITEMS.get(slot) : null; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java new file mode 100644 index 0000000..a0c1901 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/java/dev/ella/forge189/Sounds.java @@ -0,0 +1,35 @@ +package dev.ella.forge189; + +import net.minecraft.block.Block; + +/** + * Maps Ella's version-neutral sound names onto 1.8.9's sound types. + * + *

The editor deliberately exposes generic names rather than a version's own enum, so + * one project can target 1.8.9 and 26.2 at once. Translating them is an adapter's job. + * + *

1.8.9 predates the {@code SoundType} class of later versions: the types are static + * fields on {@link Block} and the nested class is {@code Block.SoundType}. Two of the + * names also differ from their modern spellings — gravel is {@code soundTypeGravel} here + * rather than {@code GROUND}, and wool is {@code soundTypeCloth}. + */ +final class Sounds { + + private Sounds() { + } + + static Block.SoundType byName(String name) { + if (name == null) return Block.soundTypeStone; + + if ("wood".equals(name)) return Block.soundTypeWood; + if ("gravel".equals(name)) return Block.soundTypeGravel; + if ("grass".equals(name)) return Block.soundTypeGrass; + if ("metal".equals(name)) return Block.soundTypeMetal; + if ("glass".equals(name)) return Block.soundTypeGlass; + if ("wool".equals(name)) return Block.soundTypeCloth; + if ("sand".equals(name)) return Block.soundTypeSand; + if ("snow".equals(name)) return Block.soundTypeSnow; + + return Block.soundTypeStone; + } +} diff --git a/mod/adapters/forge-1.8.9/src/main/resources/mcmod.info b/mod/adapters/forge-1.8.9/src/main/resources/mcmod.info new file mode 100644 index 0000000..9b2e30e --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/resources/mcmod.info @@ -0,0 +1,15 @@ +[ + { + "modid": "ella", + "name": "Ella", + "description": "Live Blockbench model testing. Binds project entries to a pool of placeholder blocks and items so models can be edited without restarting the game.", + "version": "${version}", + "mcversion": "${mcversion}", + "url": "", + "authorList": [], + "credits": "", + "logoFile": "", + "screenshots": [], + "dependencies": [] + } +] diff --git a/mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta b/mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..d121159 --- /dev/null +++ b/mod/adapters/forge-1.8.9/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "Ella", + "pack_format": 1 + } +} diff --git a/mod/adapters/forge-modern/build.gradle b/mod/adapters/forge-modern/build.gradle index c4b9411..126f520 100644 --- a/mod/adapters/forge-modern/build.gradle +++ b/mod/adapters/forge-modern/build.gradle @@ -12,7 +12,7 @@ plugins { } group = 'dev.ella' -version = '0.1.0' +version = '0.2.0' archivesBaseName = 'ella-forge-modern' java { diff --git a/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java b/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java index 729cbb8..f6f6573 100644 --- a/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java +++ b/mod/adapters/forge-modern/src/main/java/dev/ella/forgemodern/EllaMod.java @@ -21,7 +21,7 @@ public final class EllaMod { public static final String MOD_ID = "ella"; - public static final String VERSION = "0.1.0"; + public static final String VERSION = "0.2.0"; private static final int DEFAULT_SLOTS = 128; diff --git a/mod/ella-core/build.gradle b/mod/ella-core/build.gradle index 0f8601a..d6a4b30 100644 --- a/mod/ella-core/build.gradle +++ b/mod/ella-core/build.gradle @@ -14,7 +14,7 @@ plugins { } group = 'dev.ella' -version = '0.1.0' +version = '0.2.0' java { // Toolchain rather than sourceCompatibility: this must compile on a machine whose From 2345087259e4f5949e03b8af46fa95917672ee8e Mon Sep 17 00:00:00 2001 From: KinjerJS Date: Mon, 17 Aug 2026 13:51:54 +0200 Subject: [PATCH 2/2] fix(ci): mark the 1.8.9 gradle wrapper executable Committed as 100644, so the runner refused it with "Permission denied" before Gradle ever started. core.filemode is false on Windows, where the file was added, so the bit has to be set in the index by hand. Only this adapter is affected: the other two run the system `gradle`, and it is the only build carrying its own wrapper. --- mod/adapters/forge-1.8.9/gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 mod/adapters/forge-1.8.9/gradlew diff --git a/mod/adapters/forge-1.8.9/gradlew b/mod/adapters/forge-1.8.9/gradlew old mode 100644 new mode 100755