├── .gitignore ├── .gitmodules ├── .prettierignore ├── FNA.patch ├── Makefile ├── README.md ├── extract-relogic ├── Program.cs └── extract-relogic.csproj ├── fixwasm.sh ├── index.html ├── package.json ├── patch ├── Program.cs └── patch.csproj ├── pnpm-lock.yaml ├── prettier.config.js ├── public ├── AndyBold.ttf ├── backdrop.png ├── logo.png └── sw.js ├── src ├── app.tsx ├── fs.tsx ├── game.ts ├── main.tsx ├── splash.tsx ├── store.ts ├── styles.css ├── ui │ ├── Button.tsx │ ├── Dialog.tsx │ ├── Switch.tsx │ └── TextField.tsx └── vite-env.d.ts ├── terraria ├── CloudSocialModule.cs ├── Emscripten.c ├── Patches │ └── Vanilla │ │ ├── ReLogic │ │ ├── Localization │ │ │ └── IME │ │ │ │ └── FnaIme.cs.patch │ │ └── OS │ │ │ ├── Linux │ │ │ └── LinuxPlatform.cs.patch │ │ │ └── OSX │ │ │ └── OsxPlatform.cs.patch │ │ └── Terraria │ │ ├── Achievements │ │ └── AchievementManager.cs.patch │ │ ├── Audio │ │ ├── MP3AudioTrack.cs.patch │ │ ├── OGGAudioTrack.cs.patch │ │ └── SoundEngine.cs.patch │ │ ├── GameInput │ │ └── PlayerInput.cs.patch │ │ ├── Graphics │ │ ├── Capture │ │ │ └── CaptureCamera.cs.patch │ │ └── WindowStateController.cs.patch │ │ ├── Localization │ │ └── LanguageManager.cs.patch │ │ ├── Main.cs.patch │ │ ├── NPC.cs.patch │ │ ├── Net │ │ └── Sockets │ │ │ └── TcpSocket.cs.patch │ │ ├── Player.cs.patch │ │ ├── Social │ │ ├── SocialAPI.cs.patch │ │ ├── SocialMode.cs.patch │ │ └── Steam │ │ │ ├── CoreSocialModule.cs.patch │ │ │ └── NetClientSocialModule.cs.patch │ │ ├── UI │ │ └── FancyErrorPrinter.cs.patch │ │ └── Utilities │ │ ├── FileBrowser │ │ └── NativeFileDialog.cs.patch │ │ └── PlatformUtilities.cs.patch ├── Program.cs └── terraria.csproj ├── tools ├── applypatches.sh ├── copydecompiled.sh ├── decompile.sh └── genpatches.sh ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /extract-relogic/bin 2 | /extract-relogic/obj 3 | /patch/bin 4 | /patch/obj 5 | /statics 6 | /Content/ 7 | /nuget 8 | /emsdk 9 | 10 | 11 | /node_modules 12 | /public/_framework 13 | /public/resources 14 | /public/content 15 | /dist 16 | .env.local 17 | 18 | 19 | terraria/bin 20 | terraria/obj 21 | terraria/Decompiled 22 | terraria/libs 23 | terraria/Terraria 24 | terraria/ReLogic 25 | public/app.ico 26 | 27 | FNA 28 | 29 | 30 | SDL3.Legacy.cs 31 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "SteamKit2.WASM"] 2 | path = SteamKit2.WASM 3 | url = https://github.com/MercuryWorkshop/SteamKit2.WASM 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | SteamKit2.WASM 2 | -------------------------------------------------------------------------------- /FNA.patch: -------------------------------------------------------------------------------- 1 | diff --git a/FNA.csproj b/FNA.csproj 2 | index 84be250..86215eb 100644 3 | --- a/FNA.csproj 4 | +++ b/FNA.csproj 5 | @@ -409,7 +409,6 @@ 6 | 7 | 8 | 9 | - 10 | 11 | 12 | 13 | diff --git a/src/Content/ContentTypeReaderManager.cs b/src/Content/ContentTypeReaderManager.cs 14 | index 5fcf9e1..12f44fc 100644 15 | --- a/src/Content/ContentTypeReaderManager.cs 16 | +++ b/src/Content/ContentTypeReaderManager.cs 17 | @@ -21,6 +21,9 @@ using System.Text.RegularExpressions; 18 | 19 | namespace Microsoft.Xna.Framework.Content 20 | { 21 | + public class ContentTypeReaderMetaTypeManager { 22 | + public static Type BackupType; 23 | + } 24 | public sealed class ContentTypeReaderManager 25 | { 26 | #region Private Variables 27 | @@ -196,6 +199,15 @@ namespace Microsoft.Xna.Framework.Content 28 | readerTypeString = PrepareType(readerTypeString); 29 | 30 | Type l_readerType = Type.GetType(readerTypeString); 31 | + if (l_readerType == null) 32 | + { 33 | + if (readerTypeString == "Microsoft.Xna.Framework.Content.ListReader`1[[System.Char, mscorlib]]") 34 | + { 35 | + l_readerType = typeof(Microsoft.Xna.Framework.Content.ListReader); 36 | + } else { 37 | + l_readerType = ContentTypeReaderMetaTypeManager.BackupType; 38 | + } 39 | + } 40 | if (l_readerType != null) 41 | { 42 | ContentTypeReader typeReader; 43 | diff --git a/src/FNAPlatform/FNAPlatform.cs b/src/FNAPlatform/FNAPlatform.cs 44 | index c17454b..08d40eb 100644 45 | --- a/src/FNAPlatform/FNAPlatform.cs 46 | +++ b/src/FNAPlatform/FNAPlatform.cs 47 | @@ -36,7 +36,7 @@ namespace Microsoft.Xna.Framework 48 | * -flibit 49 | */ 50 | 51 | - bool useSDL3 = Environment.GetEnvironmentVariable("FNA_PLATFORM_BACKEND") == "SDL3"; 52 | + bool useSDL3 = true; 53 | 54 | if (useSDL3) 55 | { 56 | diff --git a/src/Game.cs b/src/Game.cs 57 | index 14021ec..ea1f5d2 100644 58 | --- a/src/Game.cs 59 | +++ b/src/Game.cs 60 | @@ -181,7 +181,7 @@ namespace Microsoft.Xna.Framework 61 | 62 | #region Internal Variables 63 | 64 | - internal bool RunApplication; 65 | + public bool RunApplication; 66 | 67 | #endregion 68 | 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | STATICS_RELEASE=c93989e1-7585-4b18-ae46-51fceedf9aeb 2 | Profile=Release 3 | DOTNETFLAGS=--nodereuse:false 4 | 5 | statics: 6 | mkdir statics 7 | wget https://github.com/r58Playz/FNA-WASM-Build/releases/download/$(STATICS_RELEASE)/FAudio.a -O statics/FAudio.a 8 | wget https://github.com/r58Playz/FNA-WASM-Build/releases/download/$(STATICS_RELEASE)/FNA3D.a -O statics/FNA3D.a 9 | wget https://github.com/r58Playz/FNA-WASM-Build/releases/download/$(STATICS_RELEASE)/libmojoshader.a -O statics/libmojoshader.a 10 | wget https://github.com/r58Playz/FNA-WASM-Build/releases/download/$(STATICS_RELEASE)/SDL3.a -O statics/SDL3.a 11 | wget https://github.com/r58Playz/FNA-WASM-Build/releases/download/$(STATICS_RELEASE)/libcrypto.a -O statics/libcrypto.a 12 | 13 | terraria/Decompiled: 14 | bash tools/decompile.sh 15 | terraria/Terraria: terraria/Decompiled 16 | bash tools/copydecompiled.sh 17 | bash tools/applypatches.sh Vanilla 18 | 19 | node_modules: 20 | pnpm i 21 | 22 | FNA: 23 | git clone https://github.com/FNA-XNA/FNA --recursive -b 25.02 24 | cd FNA && git apply ../FNA.patch 25 | cp FNA/lib/SDL3-CS/SDL3/SDL3.Legacy.cs SDL3.Legacy.cs 26 | 27 | deps: statics node_modules FNA terraria/Terraria 28 | 29 | # targets 30 | 31 | clean: 32 | rm -rvf statics {terraria,SteamKit2.WASM/SteamKit/SteamKit2/SteamKit2,SteamKit2.WASM/protobuf-net/src/protobuf-net.Core}/{bin,obj} FNA node_modules || true 33 | 34 | build: deps 35 | if [ $(Profile) = "Debug" ]; then\ 36 | sed 's/\[DllImport(nativeLibName, EntryPoint = "SDL_CreateWindow", CallingConvention = CallingConvention\.Cdecl)\]/[DllImport(nativeLibName, EntryPoint = "SDL__CreateWindow", CallingConvention = CallingConvention.Cdecl)]/' < SDL3.Legacy.cs |\ 37 | sed '/\[DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)\]/ { N; s|\(\[DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl)\]\)\n\(.*SDL_GetWindowFlags.*\)|[DllImport(nativeLibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "SDL__GetWindowFlags")]\n\2| }'\ 38 | > FNA/lib/SDL3-CS/SDL3/SDL3.Legacy.cs;\ 39 | else\ 40 | cp SDL3.Legacy.cs FNA/lib/SDL3-CS/SDL3/SDL3.Legacy.cs;\ 41 | fi 42 | # 43 | rm -r public/_framework terraria/bin/$(Profile)/net9.0/publish/wwwroot/_framework || true 44 | cd terraria && dotnet publish -c $(Profile) -v diag $(DOTNETFLAGS) 45 | cp -r terraria/bin/$(Profile)/net9.0/publish/wwwroot/_framework public/_framework 46 | # 47 | # microsoft messed up 48 | sed -i 's/FS_createPath("\/","usr\/share",!0,!0)/FS_createPath("\/usr","share",!0,!0)/' public/_framework/dotnet.runtime.*.js 49 | # sdl messed up 50 | sed -i 's/!window.matchMedia/!self.matchMedia/' public/_framework/dotnet.native.*.js 51 | # emscripten sucks 52 | sed -i 's/var offscreenCanvases={};/var offscreenCanvases={};if(globalThis.window\&\&!window.TRANSFERRED_CANVAS){transferredCanvasNames=[".canvas"];window.TRANSFERRED_CANVAS=true;}/' public/_framework/dotnet.native.*.js 53 | sed -i 's/var offscreenCanvases = {};/var offscreenCanvases={};if(globalThis.window\&\&!window.TRANSFERRED_CANVAS){transferredCanvasNames=[".canvas"];window.TRANSFERRED_CANVAS=true;}/' public/_framework/dotnet.native.*.js 54 | # emscripten sucks. like a lot 55 | bash fixwasm.sh 56 | 57 | serve: build 58 | pnpm dev 59 | 60 | publish: build 61 | pnpm build 62 | 63 | 64 | .PHONY: clean build serve 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terrarium 2 | 3 | A port of Terraria to the browser using WebAssembly and [fna-wasm-threads](https://github.com/r58Playz/fna-wasm-threads) 4 | 5 | Read the [writeup](https://velzie.rip/blog/celeste-wasm) for more information on how this works. 6 | ![image](https://github.com/user-attachments/assets/dae455c5-7eec-4473-9951-babc8a1b402e) 7 | 8 | # I want to host this on my website 9 | 10 | Go to the [releases page](https://github.com/MercuryWorkshop/terraria-wasm/releases) and download the latest release. Extract the contents of terraria-wasm-build.zip to your web server. Cross site isolation headers are required for this to work, so make sure your web server is configured to send the following headers: 11 | 12 | ``` 13 | Cross-Origin-Embedder-Policy: require-corp 14 | Cross-Origin-Opener-Policy: same-origin 15 | ``` 16 | 17 | # I want to build this 18 | 19 | ## Prerequisites 20 | 21 | - A x86_64 Linux system 22 | - dotnet 9.0.4 23 | - the mono-devel package on your distro 24 | - node and pnpm 25 | - Terraria (Linux or Windows build) 26 | - ilspycmd (SPECIFICALLY VERSION 9.0.0.7889) 27 | it's recommended to install ilspycmd using the .NET CLI 28 | 29 | ```bash 30 | dotnet tool install --global ilspycmd --version 9.0.0.7889 31 | ``` 32 | 33 | ## Building 34 | 35 | 1. Clone the repository (make sure you use --recursive!!) 36 | 2. Decompile Terraria 37 | 38 | ```bash 39 | bash tools/decompile.sh ~/.local/share/Steam/steamapps/common/Terraria/Terraria.exe 40 | ``` 41 | 42 | 3. Build the project 43 | 44 | ```bash 45 | make serve 46 | ``` 47 | 48 | To build the frontend for production, run: 49 | 50 | ```bash 51 | make publish 52 | ``` 53 | 54 | 62 | 63 | # I want to add mods 64 | 65 | Right now performance is not good enough in dotnet interpreted mode, which is required for [the MonoMod WASM port](https://github.com/r58Playz/MonoMod) to function. 66 | Eventually it might be possible with improvements to the dotnet wasm jit or a "mixed aot" mode allowing for better FNA performance, but for now it isn't feasible 67 | -------------------------------------------------------------------------------- /extract-relogic/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | Assembly assembly = Assembly.LoadFile(Path.GetFullPath(Environment.GetCommandLineArgs()[1])); 4 | (string, string)[] resources = assembly.GetManifestResourceNames() 5 | .Where(x => x.Contains("Terraria.Libraries")) 6 | .Select(x => 7 | { 8 | string[] arr = x.Split('.').ToArray(); 9 | var offset = 3; 10 | if (arr.Length > 4 && (string?)arr.GetValue(3) == "NET" && (string?)arr.GetValue(4) == "Linux") 11 | { 12 | offset = 5; 13 | } 14 | else if (arr.Length > 3 && ((string?)arr.GetValue(3) == "NET" || (string?)arr.GetValue(3) == "Linux")) 15 | { 16 | offset = 4; 17 | } 18 | return (x, String.Join('.', arr[offset..(arr.Length)])); 19 | }) 20 | .ToArray(); 21 | 22 | foreach (var (id, name) in resources) 23 | { 24 | Stream? stream = assembly.GetManifestResourceStream(id); 25 | if (stream == null) 26 | { 27 | throw new Exception($"Failed to get {id}"); 28 | } 29 | FileStream outstream = File.OpenWrite(Environment.GetCommandLineArgs()[2] + "/" + name); 30 | stream.CopyTo(outstream); 31 | } 32 | -------------------------------------------------------------------------------- /extract-relogic/extract-relogic.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Exe 4 | net9.0 5 | extract_relogic 6 | enable 7 | enable 8 | linux-x64 9 | 10 | 11 | -------------------------------------------------------------------------------- /fixwasm.sh: -------------------------------------------------------------------------------- 1 | set -euo pipefail 2 | 3 | wasm2wat --enable-all public/_framework/dotnet.native.*.wasm > wasm.wat 4 | 5 | ( 6 | cd patch || exit 1 7 | dotnet run -c Release ../wasm.wat ../wasmreplaced.wat 8 | ) 9 | 10 | wat2wasm --enable-all wasmreplaced.wat -o public/_framework/dotnet.native.*.wasm 11 | rm wasm.wat wasmreplaced.wat 12 | jq --arg a "sha256-$(openssl dgst -sha256 -binary public/_framework/dotnet.native.*.wasm | openssl base64 -A)" '.resources.wasmNative[.resources.wasmNative | keys[0]] = $a' public/_framework/blazor.boot.json > blazor.boot.json 13 | mv blazor.boot.json public/_framework/blazor.boot.json 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | <% if (process.env.VITE_SIMPLE_DOWNLOAD) { %> 19 | 20 | <% } %> 21 | Terrarium 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terraria-wasm", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc && vite build", 8 | "dev": "vite", 9 | "format": "prettier -w ." 10 | }, 11 | "dependencies": { 12 | "@ktibow/iconset-material-symbols": "^0.0.1747426012", 13 | "dreamland": "^0.0.25", 14 | "events": "^3.3.0", 15 | "libcurl.js": "^0.7.1", 16 | "ring-buffer-ts": "^1.2.0", 17 | "rollup-plugin-node-polyfills": "^0.2.1", 18 | "streamx-webstream": "^1.2.12", 19 | "tar-stream": "^3.1.7", 20 | "vite-plugin-html": "^3.2.2" 21 | }, 22 | "devDependencies": { 23 | "@iconify/types": "^2.0.0", 24 | "@types/tar-stream": "^3.1.3", 25 | "@types/wicg-file-system-access": "^2023.10.6", 26 | "typescript": "^5.8.3", 27 | "vite": "^6.3.5", 28 | "vite-plugin-dreamland": "^1.2.1", 29 | "prettier": "^3.5.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /patch/Program.cs: -------------------------------------------------------------------------------- 1 | string input = args[0]; 2 | string output = args[1]; 3 | 4 | var outputStream = new StreamWriter(output); 5 | 6 | string? callbackType = null; 7 | int doCallbackId = -1; 8 | 9 | using (var reader = new StreamReader(input)) 10 | { 11 | string? line = null; 12 | while ((line = reader.ReadLine()) != null) 13 | { 14 | if (callbackType == null && line.Contains("(type") && line.Contains("(func (param i32 i32 i32) (result i32))")) 15 | { 16 | callbackType = line.Split(";")[1]; 17 | Console.Error.WriteLine($"found callback type: {callbackType}"); 18 | } 19 | if (line.Contains("(elem (;0;)")) 20 | { 21 | string[] functable = string.Join("func", line.Split("func").Skip(1)).TrimEnd(')').Split(" "); 22 | doCallbackId = functable.Index().First(x => x.Item == "$do_callback").Index; 23 | Console.Error.WriteLine($"found callback index: {doCallbackId}"); 24 | } 25 | } 26 | } 27 | 28 | if (callbackType == null || doCallbackId == -1) throw new Exception(":("); 29 | 30 | bool inTarget = false; 31 | int nesting = 0; 32 | using (var reader = new StreamReader(input)) 33 | { 34 | string? line = null; 35 | while ((line = reader.ReadLine()) != null) 36 | { 37 | if (inTarget) 38 | { 39 | nesting += line.Count(x => x == '(') - line.Count(x => x == ')'); 40 | Console.Error.WriteLine(line); 41 | } 42 | else 43 | outputStream.WriteLine(line); 44 | 45 | if (nesting < 0) 46 | { 47 | inTarget = false; 48 | nesting = 0; 49 | } 50 | 51 | if (line.Contains("(func $_emscripten_run_callback_on_thread (type")) 52 | { 53 | Console.Error.WriteLine("Found emscripten proxy: {line}"); 54 | outputStream.WriteLine(""" 55 | (local i32 i32) 56 | call $emscripten_proxy_get_system_queue 57 | local.set 6 58 | i32.const 16 59 | call $dlmalloc 60 | local.tee 5 61 | local.get 4 62 | i32.store offset=12 63 | local.get 5 64 | local.get 3 65 | i32.store offset=8 66 | local.get 5 67 | local.get 2 68 | i32.store offset=4 69 | local.get 5 70 | local.get 1 71 | i32.store 72 | local.get 6 73 | local.get 0 74 | i32.const __X__ 75 | local.get 5 76 | call $emscripten_proxy_async 77 | i32.eqz 78 | if ;; label = @1 79 | i32.const 246281 80 | i32.const 164275 81 | i32.const 40 82 | i32.const 150929 83 | call $__assert_fail 84 | unreachable 85 | end) 86 | """.Replace("__X__", $"{doCallbackId}")); 87 | inTarget = true; 88 | } 89 | 90 | if (line.Contains("(func $do_callback (type")) 91 | { 92 | Console.Error.WriteLine($"Found emscripten callback: {line}"); 93 | outputStream.WriteLine(""" 94 | local.get 0 95 | i32.load offset=4 96 | local.get 0 97 | i32.load offset=8 98 | local.get 0 99 | i32.load offset=12 100 | local.get 0 101 | i32.load 102 | call_indirect (type __X__) 103 | drop 104 | local.get 0 105 | call $dlfree) 106 | """.Replace("__X__", callbackType)); 107 | inTarget = true; 108 | } 109 | } 110 | } 111 | 112 | outputStream.Flush(); 113 | outputStream.Close(); 114 | -------------------------------------------------------------------------------- /patch/patch.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /* @type {import("prettier").Config} */ 2 | export default { 3 | trailingComma: "es5", 4 | useTabs: true, 5 | }; 6 | -------------------------------------------------------------------------------- /public/AndyBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryWorkshop/terraria-wasm/192bb8ef62d534b637a9ac621bfe130def73ea5a/public/AndyBold.ttf -------------------------------------------------------------------------------- /public/backdrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryWorkshop/terraria-wasm/192bb8ef62d534b637a9ac621bfe130def73ea5a/public/backdrop.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryWorkshop/terraria-wasm/192bb8ef62d534b637a9ac621bfe130def73ea5a/public/logo.png -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("fetch", (event) => { 2 | event.respondWith( 3 | (async () => { 4 | try { 5 | if (new URL(event.request.url).pathname === "/") { 6 | maybeFlushCache(); 7 | } 8 | let req = await caches.match(event.request); 9 | if (req) { 10 | let headers = new Headers(req.headers); 11 | if (headers.get("Cross-Origin-Embedder-Policy") != "require-corp") 12 | headers.append("Cross-Origin-Embedder-Policy", "require-corp"); 13 | if (headers.get("Cross-Origin-Opener-Policy") != "same-origin") 14 | headers.append("Cross-Origin-Opener-Policy", "same-origin"); 15 | 16 | return new Response(req.body, { 17 | status: req.status, 18 | statusText: req.statusText, 19 | headers: headers, 20 | }); 21 | } 22 | req = await fetch(event.request); 23 | return req; 24 | } catch (e) { 25 | console.log("error", e); 26 | return new Response("Worker error", { 27 | status: 500, 28 | statusText: "Network error", 29 | }); 30 | } 31 | })() 32 | ); 33 | }); 34 | 35 | async function installCache() { 36 | const cache = await caches.open("v1"); 37 | const boot = await fetch("_framework/blazor.boot.json"); 38 | const bootjson = await boot.json(); 39 | let resources = [ 40 | "/", 41 | "/MILESTONE", 42 | "/_framework/blazor.boot.json", 43 | "/app.ico", 44 | "/backdrop.png", 45 | "/AndyBold.ttf", 46 | "/assets/index.js", 47 | "/assets/index.css", 48 | ...Object.keys(bootjson.resources.fingerprinting).map( 49 | (r) => "_framework/" + r 50 | ), 51 | ]; 52 | await cache.addAll(resources); 53 | } 54 | 55 | self.addEventListener("install", (event) => { 56 | event.waitUntil(installCache()); 57 | console.log("cache installed"); 58 | }); 59 | 60 | async function maybeFlushCache() { 61 | const cachedmilestone = await caches.match("/MILESTONE"); 62 | const response = await fetch("/MILESTONE"); 63 | const milestone = await response.text(); 64 | if (cachedmilestone) { 65 | const cachedmilestoneText = await cachedmilestone.text(); 66 | if (cachedmilestoneText === milestone) { 67 | console.log("cache up to date"); 68 | return; 69 | } 70 | } 71 | 72 | caches.keys().then((cacheNames) => { 73 | console.log("flushing cache"); 74 | return Promise.all(cacheNames.map((name) => caches.delete(name))); 75 | }); 76 | } 77 | 78 | self.addEventListener("activate", (event) => { 79 | event.waitUntil(maybeFlushCache()); 80 | }); 81 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import "dreamland"; 2 | import "./styles.css"; 3 | import { Main } from "./main"; 4 | import { Splash } from "./splash"; 5 | import { hasContent } from "./fs"; 6 | 7 | const initialHasContent = await hasContent(); 8 | 9 | const App: Component< 10 | {}, 11 | { 12 | el: HTMLElement; 13 | showInstructions: boolean; 14 | } 15 | > = function () { 16 | this.css = ` 17 | position: relative; 18 | 19 | div { 20 | position: absolute; 21 | width: 100%; 22 | height: 100%; 23 | top: 0; 24 | left: 0; 25 | } 26 | #splash { 27 | z-index: 1; 28 | } 29 | 30 | @keyframes fadeout { 31 | from { opacity: 1; scale: 1; } 32 | to { opacity: 0; scale: 1.2; } 33 | } 34 | `; 35 | 36 | const next = () => { 37 | this.el.addEventListener("animationend", this.el.remove); 38 | this.el.style.animation = "fadeout 0.5s ease"; 39 | }; 40 | 41 | return ( 42 |
43 | {initialHasContent ? null : ( 44 |
45 | 46 |
47 | )} 48 |
49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | const root = document.getElementById("app")!; 56 | try { 57 | root.replaceWith(); 58 | navigator.serviceWorker.register("/sw.js", { 59 | scope: "/", 60 | }); 61 | } catch (err) { 62 | console.log(err); 63 | root.replaceWith(document.createTextNode(`Failed to load: ${err}`)); 64 | } 65 | -------------------------------------------------------------------------------- /src/fs.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Icon } from "./ui/Button"; 2 | 3 | import tar, { Headers as TarHeaders, Pack } from "tar-stream"; 4 | // @ts-expect-error 5 | import { 6 | fromWeb as streamFromWeb, 7 | toWeb as streamToWeb, 8 | } from "streamx-webstream"; 9 | 10 | import iconFolder from "@ktibow/iconset-material-symbols/folder"; 11 | import iconDraft from "@ktibow/iconset-material-symbols/draft"; 12 | import iconDownload from "@ktibow/iconset-material-symbols/download"; 13 | import iconDelete from "@ktibow/iconset-material-symbols/delete"; 14 | import iconClose from "@ktibow/iconset-material-symbols/close"; 15 | import iconSave from "@ktibow/iconset-material-symbols/save"; 16 | import iconUploadFile from "@ktibow/iconset-material-symbols/upload-file"; 17 | import iconUploadFolder from "@ktibow/iconset-material-symbols/drive-folder-upload"; 18 | import iconArchive from "@ktibow/iconset-material-symbols/archive"; 19 | import iconUnarchive from "@ktibow/iconset-material-symbols/unarchive"; 20 | import iconArrowBack from "@ktibow/iconset-material-symbols/arrow-back"; 21 | 22 | export const PICKERS_UNAVAILABLE = 23 | !window.showDirectoryPicker || !window.showOpenFilePicker; 24 | 25 | export const rootFolder = await navigator.storage.getDirectory(); 26 | 27 | export const TAR_TYPES = [ 28 | { 29 | description: "TAR archive (.tar)", 30 | accept: { "application/x-tar": ".tar" }, 31 | } as FilePickerAcceptType, 32 | { 33 | description: "GZip compressed TAR archive (.tar.gz)", 34 | accept: { "application/x-gzip": ".tar.gz", "application/gzip": ".tar.gz" }, 35 | } as FilePickerAcceptType, 36 | ]; 37 | 38 | async function skipOobe() { 39 | await rootFolder.getFileHandle(".ContentExists", { create: true }); 40 | const content = await rootFolder.getDirectoryHandle("Content", { 41 | create: true, 42 | }); 43 | for (const folder of ["Fonts", "Images", "Sound"]) { 44 | await content.getDirectoryHandle(folder, { create: true }); 45 | } 46 | } 47 | (self as any).skipOobe = skipOobe; 48 | 49 | export async function copyFile( 50 | file: FileSystemFileHandle, 51 | to: FileSystemDirectoryHandle 52 | ) { 53 | const data = await file.getFile().then((r) => r.stream()); 54 | const handle = await to.getFileHandle(file.name, { create: true }); 55 | const writable = await handle.createWritable(); 56 | await data.pipeTo(writable); 57 | } 58 | 59 | export async function countFolder( 60 | folder: FileSystemDirectoryHandle 61 | ): Promise { 62 | let count = 0; 63 | async function countOne(folder: FileSystemDirectoryHandle) { 64 | for await (const [_, entry] of folder) { 65 | if (entry.kind === "file") { 66 | count++; 67 | } else { 68 | await countOne(entry); 69 | } 70 | } 71 | } 72 | await countOne(folder); 73 | return count; 74 | } 75 | 76 | export async function copyFolder( 77 | folder: FileSystemDirectoryHandle, 78 | to: FileSystemDirectoryHandle, 79 | callback?: (name: string) => void 80 | ) { 81 | async function upload( 82 | from: FileSystemDirectoryHandle, 83 | to: FileSystemDirectoryHandle 84 | ) { 85 | for await (const [name, entry] of from) { 86 | if (entry.kind === "file") { 87 | await copyFile(entry, to); 88 | if (callback) callback(name); 89 | } else { 90 | const newTo = await to.getDirectoryHandle(name, { create: true }); 91 | await upload(entry, newTo); 92 | } 93 | } 94 | } 95 | const newFolder = await to.getDirectoryHandle(folder.name, { create: true }); 96 | await upload(folder, newFolder); 97 | } 98 | 99 | export async function hasContent(): Promise { 100 | try { 101 | const directory = await rootFolder.getDirectoryHandle("Content", { 102 | create: false, 103 | }); 104 | for (const child of ["Fonts", "Images", "Sounds"]) { 105 | try { 106 | await directory.getDirectoryHandle(child, { create: false }); 107 | } catch { 108 | return false; 109 | } 110 | } 111 | await rootFolder.getFileHandle(".ContentExists", { create: false }); 112 | return true; 113 | } catch { 114 | return false; 115 | } 116 | } 117 | 118 | async function createEntry( 119 | pack: Pack, 120 | header: TarHeaders, 121 | file?: ReadableStream 122 | ) { 123 | let resolve: () => void = null!; 124 | let reject: (err: any) => void = null!; 125 | const promise = new Promise((res, rej) => { 126 | resolve = res; 127 | reject = rej; 128 | }); 129 | 130 | const entry = pack.entry(header, (err) => { 131 | if (err) reject(err); 132 | else resolve(); 133 | }); 134 | 135 | if (file) { 136 | const reader = file.getReader(); 137 | while (true) { 138 | const { value, done } = await reader.read(); 139 | if (done || !value) break; 140 | 141 | entry.write(value); 142 | } 143 | 144 | entry.end(); 145 | } else { 146 | entry.end(); 147 | } 148 | 149 | await promise; 150 | } 151 | 152 | export function createTar( 153 | folder: FileSystemDirectoryHandle, 154 | callback?: (type: "directory" | "file", name: string) => void 155 | ): ReadableStream { 156 | const archive = tar.pack(); 157 | 158 | async function pack(pathPrefix: string, folder: FileSystemDirectoryHandle) { 159 | for await (const [name, entry] of folder) { 160 | if (callback) callback(entry.kind, name); 161 | 162 | if (entry.kind == "file") { 163 | const file = await entry.getFile(); 164 | const stream = file.stream(); 165 | 166 | await createEntry( 167 | archive, 168 | { 169 | name: pathPrefix + name, 170 | type: entry.kind, 171 | size: file.size, 172 | }, 173 | stream 174 | ); 175 | } else { 176 | await createEntry(archive, { 177 | name: pathPrefix + name, 178 | type: entry.kind, 179 | }); 180 | 181 | await pack(pathPrefix + name + "/", entry); 182 | } 183 | } 184 | } 185 | pack("", folder).then(() => archive.finalize()); 186 | 187 | return streamToWeb(archive); 188 | } 189 | 190 | export async function extractTar( 191 | stream: ReadableStream, 192 | folder: FileSystemDirectoryHandle, 193 | callback?: (type: "directory" | "file", name: string) => void 194 | ) { 195 | const tarInput = streamFromWeb(stream); 196 | const archive = tar.extract(); 197 | 198 | archive.on("entry", async (header, stream, next) => { 199 | const body: ReadableStream = streamToWeb(stream); 200 | 201 | async function consume() { 202 | const reader = body.getReader(); 203 | 204 | while (true) { 205 | const { done, value } = await reader.read(); 206 | if (done || !value) break; 207 | } 208 | } 209 | 210 | const path = header.name.split("/"); 211 | if (path[path.length - 1] === "") path.pop(); 212 | if (path[0] === folder.name) path.shift(); 213 | if (path.length === 0) { 214 | await consume(); 215 | next(); 216 | return; 217 | } 218 | 219 | let handle = folder; 220 | for (const name of path.splice(0, path.length - 1)) { 221 | handle = await handle.getDirectoryHandle(name, { create: true }); 222 | } 223 | 224 | if (header.type === "directory") { 225 | await handle.getDirectoryHandle(path[0], { create: true }); 226 | await consume(); 227 | 228 | if (callback) callback("directory", path[0]); 229 | } else if (header.type === "file") { 230 | const file = await handle.getFileHandle(path[0], { create: true }); 231 | const writable = await file.createWritable(); 232 | await body.pipeTo(writable); 233 | 234 | if (callback) callback("file", path[0]); 235 | } else { 236 | await consume(); 237 | } 238 | 239 | next(); 240 | }); 241 | 242 | const promise = new Promise((res, rej) => { 243 | archive.on("finish", () => res()); 244 | archive.on("error", (err) => rej(err)); 245 | }); 246 | 247 | tarInput.pipe(archive); 248 | await promise; 249 | } 250 | 251 | export async function recursiveGetDirectory( 252 | dir: FileSystemDirectoryHandle, 253 | path: string[] 254 | ): Promise { 255 | if (path.length === 0) return dir; 256 | return recursiveGetDirectory( 257 | await dir.getDirectoryHandle(path[0]), 258 | path.slice(1) 259 | ); 260 | } 261 | 262 | export const OpfsExplorer: Component< 263 | { 264 | open: boolean; 265 | }, 266 | { 267 | path: FileSystemDirectoryHandle; 268 | components: string[]; 269 | entries: { name: string; entry: FileSystemHandle }[]; 270 | 271 | editing: FileSystemFileHandle | null; 272 | uploading: boolean; 273 | downloading: boolean; 274 | } 275 | > = function () { 276 | this.path = rootFolder; 277 | this.components = []; 278 | this.entries = []; 279 | 280 | this.uploading = false; 281 | this.downloading = false; 282 | 283 | this.css = ` 284 | display: flex; 285 | flex-direction: column; 286 | gap: 1em; 287 | min-height: min(44rem, 90vh); 288 | 289 | .path { 290 | display: flex; 291 | align-items: center; 292 | gap: 0.5rem; 293 | margin: 0 0.5rem; 294 | } 295 | .path h3 { 296 | font-family: var(--font-mono); 297 | margin: 0; 298 | } 299 | 300 | .entries { 301 | display: flex; 302 | flex-direction: column; 303 | gap: 0.5em; 304 | } 305 | 306 | .entry { 307 | display: flex; 308 | align-items: center; 309 | gap: 0.5rem; 310 | 311 | font-family: var(--font-mono); 312 | } 313 | 314 | .entry > svg { 315 | width: 1.5rem; 316 | height: 1.5rem; 317 | } 318 | 319 | .editor { 320 | display: flex; 321 | flex-direction: column; 322 | gap: 0.5em; 323 | } 324 | .editor .controls { 325 | display: flex; 326 | gap: 0.5em; 327 | align-items: center; 328 | } 329 | .editor .controls .name { 330 | font-family: var(--font-mono); 331 | } 332 | .editor textarea { 333 | min-height: 16rem; 334 | background: var(--bg-sub); 335 | color: var(--fg); 336 | border: 2px solid var(--surface4); 337 | border-radius: 0.5rem; 338 | } 339 | 340 | .expand { flex: 1 } 341 | .hidden { visibility: hidden } 342 | 343 | .archive { 344 | display: flex; 345 | flex-direction: row; 346 | gap: 0.5em; 347 | 348 | } 349 | 350 | .archive > * { 351 | flex-grow: 1; 352 | } 353 | `; 354 | 355 | useChange([this.open], () => (this.path = this.path)); 356 | 357 | useChange([this.path], async () => { 358 | this.components = (await rootFolder.resolve(this.path)) || []; 359 | 360 | let entries = []; 361 | if (this.components.length > 0) { 362 | entries.push({ 363 | name: "..", 364 | entry: await recursiveGetDirectory( 365 | rootFolder, 366 | this.components.slice(0, this.components.length - 1) 367 | ), 368 | }); 369 | } 370 | for await (const [name, entry] of this.path) { 371 | entries.push({ 372 | name, 373 | entry, 374 | }); 375 | } 376 | entries.sort((a, b) => { 377 | const kind = a.entry.kind.localeCompare(b.entry.kind); 378 | return kind === 0 ? a.name.localeCompare(b.name) : kind; 379 | }); 380 | this.entries = entries; 381 | }); 382 | 383 | const uploadFile = async () => { 384 | const files = await showOpenFilePicker({ multiple: true }); 385 | this.uploading = true; 386 | for (const file of files) { 387 | await copyFile(file, this.path); 388 | } 389 | this.path = this.path; 390 | this.uploading = false; 391 | }; 392 | const uploadFolder = async () => { 393 | const folder = await showDirectoryPicker(); 394 | this.uploading = true; 395 | await copyFolder(folder, this.path); 396 | this.path = this.path; 397 | this.uploading = false; 398 | }; 399 | const downloadArchive = async () => { 400 | const dirName = this.components.at(-1) || "terraria-wasm"; 401 | const file = await showSaveFilePicker({ 402 | excludeAcceptAllOption: true, 403 | suggestedName: dirName + ".tar", 404 | types: TAR_TYPES, 405 | }); 406 | 407 | this.downloading = true; 408 | 409 | let tar = createTar(this.path, (type, name) => 410 | console.log(`tarring ${type} ${name}`) 411 | ); 412 | if (file.name.endsWith(".gz")) 413 | tar = tar.pipeThrough(new CompressionStream("gzip")); 414 | 415 | const fileStream = await file.createWritable(); 416 | await tar.pipeTo(fileStream); 417 | 418 | this.downloading = false; 419 | }; 420 | const uploadArchive = async () => { 421 | const files = await showOpenFilePicker({ multiple: true }); 422 | this.uploading = true; 423 | for (const file of files) { 424 | let tar = await file.getFile().then((r) => r.stream()); 425 | if (file.name.endsWith(".gz")) 426 | tar = tar.pipeThrough(new DecompressionStream("gzip")); 427 | await extractTar(tar, this.path, (type, name) => 428 | console.log(`untarring ${type} ${name}`) 429 | ); 430 | } 431 | this.uploading = false; 432 | }; 433 | 434 | const uploadDisabled = use(this.uploading, (x) => x || PICKERS_UNAVAILABLE); 435 | const downloadDisabled = use( 436 | this.downloading, 437 | (x) => x || PICKERS_UNAVAILABLE 438 | ); 439 | 440 | return ( 441 |
442 |
443 | {$if( 444 | use(this.components, (x) => x.length > 0), 445 | 456 | )} 457 |

458 | {use(this.components, (x) => 459 | x.length == 0 ? "Root Directory" : "/" + x.join("/") 460 | )} 461 |

462 |
463 | 472 | 481 |
482 | {$if(use(this.uploading), Uploading files...)} 483 | {$if(use(this.downloading), Downloading files...)} 484 |
485 | {use(this.entries, (x) => 486 | x 487 | .filter((x) => x.name != "..") 488 | .map((x) => { 489 | const icon = 490 | x.entry.kind === "directory" ? iconFolder : iconDraft; 491 | const remove = async (e: Event) => { 492 | e.stopImmediatePropagation(); 493 | if (this.editing?.name === x.name) { 494 | this.editing = null; 495 | } 496 | await this.path.removeEntry(x.name, { recursive: true }); 497 | this.path = this.path; 498 | }; 499 | const download = async (e: Event) => { 500 | e.stopImmediatePropagation(); 501 | if (x.entry.kind === "file") { 502 | const entry = x.entry as FileSystemFileHandle; 503 | const blob = await entry.getFile(); 504 | 505 | const url = URL.createObjectURL(blob); 506 | const a = document.createElement("a"); 507 | a.href = url; 508 | a.download = x.name; 509 | a.click(); 510 | 511 | await new Promise((r) => setTimeout(r, 100)); 512 | URL.revokeObjectURL(url); 513 | } 514 | }; 515 | const action = () => { 516 | if (x.entry.kind === "directory") { 517 | this.editing = null; 518 | this.path = x.entry as FileSystemDirectoryHandle; 519 | } else { 520 | this.editing = x.entry as FileSystemFileHandle; 521 | } 522 | }; 523 | 524 | return ( 525 | 545 | 555 | 556 | ); 557 | }) 558 | )} 559 |
560 | {use(this.editing, (file) => { 561 | if (file) { 562 | const area = (