diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..33f752e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "wiki-annotator", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8765", "--directory", "D:\\wiki_images"], + "port": 8765 + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8b8b6b7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(gh repo:*)", + "Bash(gh api:*)" + ] + } +} diff --git a/.claude/skills/github-release/SKILL.md b/.claude/skills/github-release/SKILL.md new file mode 100644 index 0000000..15398d5 --- /dev/null +++ b/.claude/skills/github-release/SKILL.md @@ -0,0 +1,111 @@ +--- +name: github-release +description: Use this skill when the user asks to create a GitHub release, publish a release, cut a release, or make a new release of EmoTracker. Handles version bumping, committing, waiting for CI, and publishing a release with build artifacts. +--- + +# GitHub Release + +Use this skill to publish a new GitHub release of EmoTracker. Follow every step in order — do NOT skip steps or assume defaults. + +## Step 1: Interview the user + +Before doing anything else, ask the user for the following information (use the AskUserQuestion tool if available, otherwise ask directly): + +1. **Branch** — Which branch should the release be built from? (e.g. `avalonia`, `main`) +2. **Version number** — The new version number in `Major.Minor.Build.Revision` form (e.g. `3.0.2.0`). +3. **Prerelease** — Is this a prerelease? (yes/no) + +Do not proceed until you have all three answers. + +## Step 2: Update assembly versions + +Update `AssemblyVersion` and `AssemblyFileVersion` to the user-specified version in ALL of these files: + +- `EmoTracker/Properties/AssemblyInfo.cs` +- `EmoTracker.UI/Properties/AssemblyInfo.cs` +- `EmoTracker.Data/Properties/AssemblyInfo.cs` +- `EmoTracker.Core/Properties/AssemblyInfo.cs` + +Both attributes in each file should be updated: +```csharp +[assembly: AssemblyVersion("X.Y.Z.W")] +[assembly: AssemblyFileVersion("X.Y.Z.W")] +``` + +After editing, build the solution to confirm it's clean: +``` +dotnet build EmoTracker/EmoTracker.csproj +``` +Abort and report the error if the build is not clean (0 errors). + +## Step 3: Commit and push + +Make sure you're on (or pushing to) the branch the user specified. + +Commit the assembly version changes with a message like: +``` +Bump assembly versions to X.Y.Z.W +``` + +Include the standard `Co-Authored-By: Claude Opus 4.6 ` trailer. + +Push to the remote branch the user specified: +``` +git push origin HEAD: +``` + +Note the commit SHA — you'll need it in Step 4. + +## Step 4: Wait for CI build to pass + +EmoTracker uses GitHub Actions to produce build artifacts. After the push, poll for the workflow run that corresponds to the commit you just pushed, and wait for it to complete successfully. + +Use `gh run list` to find the run and `gh run view ` to check status. Use `gh run watch ` if available to block until completion. + +```bash +# Find the run for the commit +gh run list --branch --limit 5 +# Watch the run until it completes +gh run watch +``` + +If the run fails, report the failure to the user and stop. Do not proceed to create a release on a failed build. + +## Step 5: Create the release + +Once the build has succeeded: + +1. **Determine the tag name**: `X.Y.Z.W` for a stable release, or `X.Y.Z.W-preview` for a prerelease. +2. **Download all artifacts** from the successful workflow run: + ```bash + gh run download --dir ./release-artifacts + ``` +3. **Gather release notes content**: + - Use `gh release view --json tagName` (or `gh release list`) to find the last release's tag. + - Get the commit log between the last release tag and the current commit: + ```bash + git log ..HEAD --oneline + ``` + - Summarize the changes into a concise description of what's new in this release. +4. **Create the release** targeting the commit on the specified branch, uploading all downloaded artifacts, and using GitHub's auto-generated notes plus your summary: + ```bash + gh release create \ + --target \ + --title "" \ + --generate-notes \ + --notes "" \ + [--prerelease] \ + ./release-artifacts/**/* + ``` + - Add `--prerelease` ONLY if the user said this is a prerelease. + - `--generate-notes` gets GitHub's automatic "what's changed" section; combine with `--notes` to prepend your own summary. If both can't be combined in a single invocation, create the release with `--generate-notes` first, then edit it with `gh release edit --notes ""`. + +5. **Verify** the release was created successfully and report the release URL to the user. + +## Important notes + +- Never skip the interview step — always confirm branch, version, and prerelease status before acting. +- Never create a release from a failed or in-progress build. +- Never force-push or bypass branch protection. If the branch is protected and direct push fails, report to the user and stop. +- Always use a HEREDOC for commit messages to preserve formatting. +- The existing repo has commits like `6f8fc1e Bump assembly versions to 3.0.1.0` — match that commit message style. diff --git a/.github/workflows/build-avalonia.yml b/.github/workflows/build-avalonia.yml new file mode 100644 index 0000000..df0583c --- /dev/null +++ b/.github/workflows/build-avalonia.yml @@ -0,0 +1,211 @@ +name: Build Avalonia (Cross-Platform) + +on: + push: + branches: [avalonia] + pull_request: + branches: [avalonia] + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + artifact: EmoTracker-win-x64 + - os: ubuntu-latest + rid: linux-x64 + artifact: EmoTracker-linux-x64 + + runs-on: ${{ matrix.os }} + + env: + # Allow restoring the net8.0-windows target on non-Windows (skips WPF-specific build) + EnableWindowsTargeting: true + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Publish + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime ${{ matrix.rid }} --self-contained --output publish/raw + + - name: Extract version + id: version + shell: bash + run: | + VER=$(sed -n 's/.*AssemblyFileVersion("\([^"]*\)").*/\1/p' EmoTracker/Properties/AssemblyInfo.cs) + echo "app_version=$VER" >> "$GITHUB_OUTPUT" + echo "Detected version: $VER" + + - name: Create Linux tar.xz + if: startsWith(matrix.rid, 'linux') + run: | + chmod +x publish/raw/EmoTracker + cd publish/raw + tar cJf ../EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }}.tar.xz . + + - name: Upload Linux artifact + if: startsWith(matrix.rid, 'linux') + uses: actions/upload-artifact@v4 + with: + name: EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }} + path: publish/EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }}.tar.xz + + - name: Bundle PortAudio (Windows only) + if: startsWith(matrix.rid, 'win') + shell: bash + run: | + curl -fsSL "https://github.com/spatialaudio/portaudio-binaries/raw/master/libportaudio64bit.dll" \ + -o "publish/raw/portaudio.dll" + + - name: Stage Windows output + if: startsWith(matrix.rid, 'win') + run: | + mkdir -p "publish/EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }}" + cp -R publish/raw/* "publish/EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }}/" + + - name: Upload Windows artifact + if: startsWith(matrix.rid, 'win') + uses: actions/upload-artifact@v4 + with: + name: EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }} + path: publish/EmoTracker-${{ steps.version.outputs.app_version }}-${{ matrix.rid }} + + build-macos: + runs-on: macos-latest + + env: + EnableWindowsTargeting: true + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Publish x64 + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime osx-x64 --self-contained --output publish/x64 + + - name: Publish arm64 + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime osx-arm64 --self-contained --output publish/arm64 + + - name: Extract version + id: version + run: | + VER=$(sed -n 's/.*AssemblyFileVersion("\([^"]*\)").*/\1/p' EmoTracker/Properties/AssemblyInfo.cs) + echo "app_version=$VER" >> "$GITHUB_OUTPUT" + echo "Detected version: $VER" + + - name: Create universal binary + run: | + mkdir -p publish/universal + + # Copy arm64 as the base (all managed DLLs are identical between architectures) + cp -R publish/arm64/* publish/universal/ + + # Find all Mach-O binaries and create universal versions with lipo + cd publish + find arm64 -type f | while read arm64_file; do + rel="${arm64_file#arm64/}" + x64_file="x64/$rel" + universal_file="universal/$rel" + + if [ ! -f "$x64_file" ]; then + continue + fi + + # Check if the file is a Mach-O binary + if file "$arm64_file" | grep -q "Mach-O"; then + # Get architectures of each file + arm64_archs=$(lipo -archs "$arm64_file" 2>/dev/null || true) + x64_archs=$(lipo -archs "$x64_file" 2>/dev/null || true) + + if [ "$arm64_archs" = "$x64_archs" ]; then + echo "Skipping $rel (both are $arm64_archs, already identical)" + else + echo "Creating universal binary: $rel ($arm64_archs + $x64_archs)" + lipo -create "$arm64_file" "$x64_file" -output "$universal_file" + fi + fi + done + + - name: Create macOS app bundle + run: | + APP="publish/EmoTracker-osx-universal/EmoTracker.app" + mkdir -p "$APP/Contents/MacOS" + mkdir -p "$APP/Contents/Resources" + + # Copy universal output into the bundle + cp -R publish/universal/* "$APP/Contents/MacOS/" + cp EmoTracker/macOS/Info.plist "$APP/Contents/" + chmod +x "$APP/Contents/MacOS/EmoTracker" + + # Convert .ico to .icns for the app icon + python3 -m venv .venv + source .venv/bin/activate + pip install --quiet Pillow + python3 -c " + from PIL import Image, ImageDraw + import os + + ico = Image.open('EmoTracker/emohead_icon_transparent_7h3_icon.ico') + + # Extract the largest frame from the ICO + best = None + for size in sorted(ico.info.get('sizes', [(ico.width, ico.height)]), reverse=True): + ico.size = size + candidate = ico.copy().convert('RGBA') + if best is None or candidate.width > best.width: + best = candidate + src = best + + iconset = 'EmoTracker.iconset' + os.makedirs(iconset, exist_ok=True) + + def make_icon(src, s): + # Create black rounded-rect background, composite icon on top + bg = Image.new('RGBA', (s, s), (0, 0, 0, 0)) + draw = ImageDraw.Draw(bg) + r = max(s // 5, 4) + draw.rounded_rectangle([0, 0, s - 1, s - 1], radius=r, fill=(0, 0, 0, 255)) + resized = src.resize((s, s), Image.LANCZOS) + bg.paste(resized, (0, 0), resized) + return bg + + sizes = [16, 32, 64, 128, 256, 512] + for s in sizes: + icon = make_icon(src, s) + icon.save(os.path.join(iconset, f'icon_{s}x{s}.png')) + if s >= 32: + half = s // 2 + icon.save(os.path.join(iconset, f'icon_{half}x{half}@2x.png')) + + icon = make_icon(src, 1024) + icon.save(os.path.join(iconset, 'icon_512x512@2x.png')) + " + iconutil -c icns EmoTracker.iconset -o "$APP/Contents/Resources/EmoTracker.icns" + + # Ad-hoc code sign so macOS doesn't report the bundle as damaged + codesign --force --deep -s - "$APP" + + - name: Create macOS tar.gz + run: | + cd publish/EmoTracker-osx-universal + tar czf ../EmoTracker-${{ steps.version.outputs.app_version }}-osx-universal.tar.gz EmoTracker.app + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: EmoTracker-${{ steps.version.outputs.app_version }}-osx-universal + path: publish/EmoTracker-${{ steps.version.outputs.app_version }}-osx-universal.tar.gz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1883b6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,147 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*.*' + +jobs: + release: + runs-on: macos-latest + + permissions: + contents: write + + env: + EnableWindowsTargeting: true + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Extract version + id: version + run: | + VER=$(sed -n 's/.*AssemblyFileVersion("\([^"]*\)").*/\1/p' EmoTracker/Properties/AssemblyInfo.cs) + echo "app_version=$VER" >> "$GITHUB_OUTPUT" + echo "Detected version: $VER" + + - name: Publish win-x64 + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime win-x64 --self-contained --output publish/win-x64 + + - name: Bundle PortAudio for Windows + shell: bash + run: | + curl -fsSL "https://github.com/spatialaudio/portaudio-binaries/raw/master/libportaudio64bit.dll" \ + -o "publish/win-x64/portaudio.dll" + + - name: Publish osx-x64 + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime osx-x64 --self-contained --output publish/osx-x64 + + - name: Publish osx-arm64 + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime osx-arm64 --self-contained --output publish/osx-arm64 + + - name: Publish linux-x64 + run: dotnet publish EmoTracker/EmoTracker.csproj --framework net8.0 --configuration Release --runtime linux-x64 --self-contained --output publish/linux-x64 + + - name: Create universal macOS binary + run: | + mkdir -p publish/universal + cp -R publish/osx-arm64/* publish/universal/ + cd publish + find osx-arm64 -type f | while read arm64_file; do + rel="${arm64_file#osx-arm64/}" + x64_file="osx-x64/$rel" + universal_file="universal/$rel" + if [ ! -f "$x64_file" ]; then continue; fi + if file "$arm64_file" | grep -q "Mach-O"; then + arm64_archs=$(lipo -archs "$arm64_file" 2>/dev/null || true) + x64_archs=$(lipo -archs "$x64_file" 2>/dev/null || true) + if [ "$arm64_archs" != "$x64_archs" ]; then + echo "Creating universal binary: $rel" + lipo -create "$arm64_file" "$x64_file" -output "$universal_file" + fi + fi + done + + - name: Create macOS app bundle + run: | + VER="${{ steps.version.outputs.app_version }}" + APP="publish/EmoTracker.app" + mkdir -p "$APP/Contents/MacOS" + mkdir -p "$APP/Contents/Resources" + cp -R publish/universal/* "$APP/Contents/MacOS/" + cp EmoTracker/macOS/Info.plist "$APP/Contents/" + chmod +x "$APP/Contents/MacOS/EmoTracker" + + python3 -m venv .venv + source .venv/bin/activate + pip install --quiet Pillow + python3 -c " + from PIL import Image, ImageDraw + import os + ico = Image.open('EmoTracker/emohead_icon_transparent_7h3_icon.ico') + best = None + for size in sorted(ico.info.get('sizes', [(ico.width, ico.height)]), reverse=True): + ico.size = size + candidate = ico.copy().convert('RGBA') + if best is None or candidate.width > best.width: + best = candidate + src = best + iconset = 'EmoTracker.iconset' + os.makedirs(iconset, exist_ok=True) + def make_icon(src, s): + bg = Image.new('RGBA', (s, s), (0, 0, 0, 0)) + draw = ImageDraw.Draw(bg) + r = max(s // 5, 4) + draw.rounded_rectangle([0, 0, s - 1, s - 1], radius=r, fill=(0, 0, 0, 255)) + resized = src.resize((s, s), Image.LANCZOS) + bg.paste(resized, (0, 0), resized) + return bg + sizes = [16, 32, 64, 128, 256, 512] + for s in sizes: + icon = make_icon(src, s) + icon.save(os.path.join(iconset, f'icon_{s}x{s}.png')) + if s >= 32: + half = s // 2 + icon.save(os.path.join(iconset, f'icon_{half}x{half}@2x.png')) + icon = make_icon(src, 1024) + icon.save(os.path.join(iconset, 'icon_512x512@2x.png')) + " + iconutil -c icns EmoTracker.iconset -o "$APP/Contents/Resources/EmoTracker.icns" + codesign --force --deep -s - "$APP" + + - name: Package artifacts + run: | + VER="${{ steps.version.outputs.app_version }}" + mkdir -p dist + + # Windows zip + cd publish/win-x64 + zip -r "../../dist/EmoTracker-${VER}-win-x64.zip" . + cd ../.. + + # macOS tar.gz + tar czf "dist/EmoTracker-${VER}-osx-universal.tar.gz" -C publish EmoTracker.app + + # Linux tar.xz + chmod +x publish/linux-x64/EmoTracker + tar cJf "dist/EmoTracker-${VER}-linux-x64.tar.xz" -C publish/linux-x64 . + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VER="${{ steps.version.outputs.app_version }}" + TAG="${{ github.ref_name }}" + gh release create "$TAG" \ + --title "EmoTracker $VER" \ + --notes "See the [changelog](https://github.com/EmoTracker-Community/EmoTracker/releases/tag/$TAG) for details." \ + dist/EmoTracker-${VER}-win-x64.zip \ + dist/EmoTracker-${VER}-osx-universal.tar.gz \ + dist/EmoTracker-${VER}-linux-x64.tar.xz diff --git a/.gitignore b/.gitignore index e7134bb..ad9b17e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,14 @@ ScaffoldingReadMe.txt *~ CodeCoverage/ +# Visual Studio user-specific files +*.user +*.suo +.vs/ + +# Claude Code local settings +.claude/*.local.* + # MSBuild Binary and Structured Log *.binlog diff --git a/.vscode/launch.json b/.vscode/launch.json index 3dca8dc..44a3e7a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,19 +2,30 @@ "version": "0.2.0", "configurations": [ { - "name": "Launch EmoTracker (Debug)", - "type": "clr", + "name": "Launch EmoTracker Avalonia (Debug)", + "type": "coreclr", "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/EmoTracker/bin/Debug/net472/EmoTracker.exe", + "preLaunchTask": "build Avalonia (Debug)", + "program": "${workspaceFolder}/EmoTracker/bin/Debug/net8.0/EmoTracker.dll", "args": [], - "cwd": "${workspaceFolder}/EmoTracker/bin/Debug/net472", + "cwd": "${workspaceFolder}/EmoTracker/bin/Debug/net8.0", + "stopAtEntry": false, + "console": "internalConsole" + }, + { + "name": "Launch EmoTracker Avalonia (Dev-Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build Avalonia (Debug)", + "program": "${workspaceFolder}/EmoTracker/bin/Debug/net8.0/EmoTracker.dll", + "args": ["-dev"], + "cwd": "${workspaceFolder}/EmoTracker/bin/Debug/net8.0", "stopAtEntry": false, "console": "internalConsole" }, { "name": "Attach to EmoTracker", - "type": "clr", + "type": "coreclr", "request": "attach" } ] diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..9d1d5fe --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "emotracker-mcp": { + "type": "http", + "url": "http://localhost:27125/" + } + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 90cf0ea..cc041de 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,6 +17,22 @@ "isDefault": true } }, + { + "label": "build Avalonia (Debug)", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/EmoTracker/EmoTracker.csproj", + "--framework", + "net8.0", + "--configuration", + "Debug", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, { "label": "build Release", "command": "dotnet", diff --git a/EmoTracker.Core/DotNetFrameworkVersion.cs b/EmoTracker.Core/DotNetFrameworkVersion.cs index 2a3d420..aa31b70 100644 --- a/EmoTracker.Core/DotNetFrameworkVersion.cs +++ b/EmoTracker.Core/DotNetFrameworkVersion.cs @@ -1,55 +1,9 @@ -using Microsoft.Win32; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace EmoTracker.Core +namespace EmoTracker.Core { public class DotNetFrameworkVersion { - public static void CheckVersion() - { - const string subkey = @"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full\"; - - using (RegistryKey ndpKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(subkey)) - { - if (ndpKey != null && ndpKey.GetValue("Release") != null) - { - Console.WriteLine(".NET Framework Version: " + CheckFor45PlusVersion((int)ndpKey.GetValue("Release"))); - } - else - { - Console.WriteLine(".NET Framework Version 4.5 or later is not detected."); - } - } - } - - // Checking the version using >= will enable forward compatibility. - private static string CheckFor45PlusVersion(int releaseKey) - { - if (releaseKey >= 461808) - return "4.7.2 or later"; - if (releaseKey >= 461308) - return "4.7.1"; - if (releaseKey >= 460798) - return "4.7"; - if (releaseKey >= 394802) - return "4.6.2"; - if (releaseKey >= 394254) - return "4.6.1"; - if (releaseKey >= 393295) - return "4.6"; - if (releaseKey >= 379893) - return "4.5.2"; - if (releaseKey >= 378675) - return "4.5.1"; - if (releaseKey >= 378389) - return "4.5"; - // This code should never execute. A non-null release key should mean - // that 4.5 or later is installed. - return "No 4.5 or later version detected"; - } + // .NET Framework version checking is only relevant on Windows with .NET Framework. + // On .NET 8+ this class is a no-op. + public static void CheckVersion() { } } } diff --git a/EmoTracker.Core/EmoTracker.Core.csproj b/EmoTracker.Core/EmoTracker.Core.csproj index 88ddfa3..ceedf2c 100644 --- a/EmoTracker.Core/EmoTracker.Core.csproj +++ b/EmoTracker.Core/EmoTracker.Core.csproj @@ -5,7 +5,7 @@ Library EmoTracker.Core EmoTracker.Core - net472 + net8.0 false diff --git a/EmoTracker.Core/ObservableObject.cs b/EmoTracker.Core/ObservableObject.cs index 3ffb642..bef31a8 100644 --- a/EmoTracker.Core/ObservableObject.cs +++ b/EmoTracker.Core/ObservableObject.cs @@ -50,6 +50,60 @@ public abstract class ObservableObject : INotifyPropertyChanged, INotifyProperty public event PropertyChangedEventHandler PropertyChanged; public event PropertyChangingEventHandler PropertyChanging; + // --- Notification suspension --- + // Wrapping a batch operation in SuspendNotifications() causes all PropertyChanged + // calls to be queued and deduplicated; they fire together when the returned + // IDisposable is disposed. PropertyChanging is dropped during suspension because + // the change has already occurred by the time the flush runs. + // Supports nesting: notifications resume only when the outermost scope is disposed. + + static int _suspendDepth = 0; + static readonly object _suspendLock = new object(); + static Dictionary> _pendingNotifications = null; + + public static IDisposable SuspendNotifications() + { + lock (_suspendLock) + { + if (_suspendDepth == 0) + _pendingNotifications = new Dictionary>(); + _suspendDepth++; + } + return new NotificationSuspension(); + } + + static void ResumeNotifications() + { + Dictionary> pending; + lock (_suspendLock) + { + if (--_suspendDepth > 0) + return; + pending = _pendingNotifications; + _pendingNotifications = null; + } + if (pending != null) + foreach (var kvp in pending) + foreach (var name in kvp.Value) + kvp.Key.FirePropertyChanged(name); + } + + void FirePropertyChanged(string propertyName) + { + var pc = PropertyChanged; + if (pc != null) + { + pc(this, new PropertyChangedEventArgs(propertyName)); + NotifyDependentProperties(pc, this.GetType(), propertyName); + } + } + + sealed class NotificationSuspension : IDisposable + { + bool _disposed; + public void Dispose() { if (!_disposed) { _disposed = true; ResumeNotifications(); } } + } + /// /// Generates property change notifications for all dependent properties of the /// specified property, recursively (depth first). @@ -88,18 +142,29 @@ void NotifyDependentProperties(PropertyChangingEventHandler handler, System.Type protected virtual void NotifyPropertyChanged([CallerMemberName] string propertyName = null) { - var pc = PropertyChanged; - if (pc != null) + lock (_suspendLock) { - pc(this, new PropertyChangedEventArgs(propertyName)); - - // Notify for dependent properties as well - NotifyDependentProperties(pc, this.GetType(), propertyName); + if (_suspendDepth > 0) + { + if (!_pendingNotifications.TryGetValue(this, out var names)) + _pendingNotifications[this] = names = new HashSet(); + names.Add(propertyName); + return; + } } + FirePropertyChanged(propertyName); } protected virtual void NotifyPropertyChanging([CallerMemberName] string propertyName = null) { + // Pre-change notifications are dropped during suspension: by the time the + // queued PropertyChanged events flush, the change has already taken effect. + lock (_suspendLock) + { + if (_suspendDepth > 0) + return; + } + var pc = PropertyChanging; if (pc != null) { diff --git a/EmoTracker.Core/Properties/AssemblyInfo.cs b/EmoTracker.Core/Properties/AssemblyInfo.cs index af4938b..d42043f 100644 --- a/EmoTracker.Core/Properties/AssemblyInfo.cs +++ b/EmoTracker.Core/Properties/AssemblyInfo.cs @@ -1,36 +1,36 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("EmoTracker.Core")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("EmoTracker.Core")] -[assembly: AssemblyCopyright("Copyright © 2019")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("49c7d6e4-54ac-40d8-865d-f242c0e1247c")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("EmoTracker.Core")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("EmoTracker.Core")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("49c7d6e4-54ac-40d8-865d-f242c0e1247c")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("3.0.1.11")] +[assembly: AssemblyVersion("3.0.1.11")] +[assembly: AssemblyFileVersion("3.0.1.11")] diff --git a/EmoTracker.Data/Abstracts/IGamePackage.cs b/EmoTracker.Data/Abstracts/IGamePackage.cs index 5155576..2da4898 100644 --- a/EmoTracker.Data/Abstracts/IGamePackage.cs +++ b/EmoTracker.Data/Abstracts/IGamePackage.cs @@ -24,6 +24,8 @@ public interface IGamePackage bool FlaggedAsUnsafe { get; } + IReadOnlyList AutoTrackerProviders { get; } + IGamePackageSource Source { get; } IEnumerable AvailableVariants { get; } diff --git a/EmoTracker.Data/ApplicationSettings.cs b/EmoTracker.Data/ApplicationSettings.cs index fae9271..92aaf25 100644 --- a/EmoTracker.Data/ApplicationSettings.cs +++ b/EmoTracker.Data/ApplicationSettings.cs @@ -11,25 +11,40 @@ namespace EmoTracker.Data { - public enum MultiworldNotificationLevel - { - None = 0, - Normal = 1, - Verbose = 2 - } + public class ApplicationSettings : ObservableSingleton { + readonly Dictionary mProviderSettings = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public string GetProviderSetting(string key, string defaultValue = null) + { + if (mProviderSettings.TryGetValue(key, out var value)) + return value; + return defaultValue; + } + + public void SetProviderSetting(string key, string value) + { + if (value == null) + mProviderSettings.Remove(key); + else + mProviderSettings[key] = value; + WriteSettings(); + } + double mInitialWidth = -1.0; double mInitialHeight = -1.0; double mNDIFrameRate = 30.0; int mNDIOutputScale = 1; + bool mbEnableBackgroundNdi = true; + bool mbEnableAutoUpdateCheck = true; bool mbAlwaysOnTop = false; bool mbEnableDiscordRichPresence = false; bool mbEnableVoice = true; - bool mbEnableBontaMultiWorld = false; - bool mbIgnoreBontaMultiWorldRomCheck = false; + bool mbEnableNoteTaking = true; bool mbPromptOnRefreshClose = false; + string mbVoiceInputDeviceName; private bool mbDisplayAllLocations = false; private bool mbIgnoreAllLogic = false; @@ -84,16 +99,10 @@ public bool FastToolTips string mLastActivePackageVariant; string mCommandLinePackage; string mCommandLinePackageVariant; + bool mNoAsyncImages; ObservableCollection mPackageRepositories = new ObservableCollection(); - MultiworldNotificationLevel mMultiworldNotificationLevel = MultiworldNotificationLevel.Normal; - public MultiworldNotificationLevel MultiworldNotificationLevel - { - get { return mMultiworldNotificationLevel; } - set { SetProperty(ref mMultiworldNotificationLevel, value); } - } - public double InitialWidth { get { return mInitialWidth; } @@ -118,16 +127,16 @@ public bool EnableVoiceControl set { SetProperty(ref mbEnableVoice, value); } } - public bool EnableBontaMultiWorld + public bool EnableNoteTaking { - get { return mbEnableBontaMultiWorld; } - set { SetProperty(ref mbEnableBontaMultiWorld, value); } + get { return mbEnableNoteTaking; } + set { SetProperty(ref mbEnableNoteTaking, value); } } - public bool IgnoreBontaMultiWorldRomCheck + public string VoiceInputDeviceName { - get { return mbIgnoreBontaMultiWorldRomCheck; } - set { SetProperty(ref mbIgnoreBontaMultiWorldRomCheck, value); } + get { return mbVoiceInputDeviceName; } + set { SetProperty(ref mbVoiceInputDeviceName, value); } } public bool PromptOnRefreshClose @@ -172,6 +181,17 @@ public string CommandLinePackageVariant set { SetProperty(ref mCommandLinePackageVariant, value); } } + /// + /// When true, disables async background image pre-caching and forces + /// synchronous image resolution on the UI thread (the pre-refactor + /// behavior). Set via the --no-async-images command-line flag. + /// + public bool NoAsyncImages + { + get { return mNoAsyncImages; } + set { SetProperty(ref mNoAsyncImages, value); } + } + public string TwitchChannelName { get { return mTwitchChannelName; } @@ -188,6 +208,25 @@ public int NdiOutputScale set { SetProperty(ref mNDIOutputScale, Math.Max(value, 1)); } } + /// + /// When enabled (default), the broadcast view renders to a hidden off-screen + /// window so the NDI source is advertised on the network and frames flow to + /// receivers whether or not the user has opened the visible broadcast view. + /// When disabled, NDI is only broadcast while the visible broadcast view + /// window is open (legacy behaviour). + /// + public bool EnableBackgroundNdi + { + get { return mbEnableBackgroundNdi; } + set { SetProperty(ref mbEnableBackgroundNdi, value); } + } + + public bool EnableAutoUpdateCheck + { + get { return mbEnableAutoUpdateCheck; } + set { SetProperty(ref mbEnableAutoUpdateCheck, value); } + } + public IEnumerable AdditionalRepositories { get { return mPackageRepositories; } @@ -225,12 +264,13 @@ private void LoadSettings() InitialHeight = root.GetValue("initial_height", -1.0); NdiFrameRate = root.GetValue("ndi_frame_rate", 30.0); NdiOutputScale = root.GetValue("ndi_output_scale", 1); + EnableBackgroundNdi = root.GetValue("enable_background_ndi", true); + EnableAutoUpdateCheck = root.GetValue("enable_auto_update_check", true); AlwaysOnTop = root.GetValue("always_on_top", false); EnableDiscordRichPresence = root.GetValue("discord_rich_presence", false); EnableVoiceControl = root.GetValue("enable_voice_control", true); - EnableBontaMultiWorld = root.GetValue("enable_bonta_alttpr_multiworld", false); - IgnoreBontaMultiWorldRomCheck = root.GetValue("ignore_bonta_alttpr_multiworld_rom_check", false); - MultiworldNotificationLevel = root.GetEnumValue("multiworld_notification_level", MultiworldNotificationLevel.Normal); + EnableNoteTaking = root.GetValue("enable_note_taking", true); + VoiceInputDeviceName = root.GetValue("voice_input_device_name"); PromptOnRefreshClose = root.GetValue("prompt_on_refresh_close", false); LastActivePackage = root.GetValue("last_active_package"); LastActivePackageVariant = root.GetValue("last_active_package_variant"); @@ -254,6 +294,16 @@ private void LoadSettings() mPackageRepositories.Add(url); } } + + JObject providerSettings = root.GetValue("provider_settings"); + if (providerSettings != null) + { + foreach (var kvp in providerSettings) + { + if (kvp.Value != null && kvp.Value.Type == JTokenType.String) + mProviderSettings[kvp.Key] = kvp.Value.Value(); + } + } } } @@ -279,6 +329,11 @@ private void LoadSettings() } + if (String.Equals(cargs[n], "--no-async-images")) + { + NoAsyncImages = true; + } + } } catch @@ -311,12 +366,16 @@ private void WriteSettings() if (NdiOutputScale > 1) root.Add("ndi_output_scale", JToken.FromObject(NdiOutputScale)); + root.Add("enable_background_ndi", JToken.FromObject(EnableBackgroundNdi)); + root.Add("enable_auto_update_check", JToken.FromObject(EnableAutoUpdateCheck)); + root.Add("always_on_top", JToken.FromObject(AlwaysOnTop)); root.Add("discord_rich_presence", JToken.FromObject(EnableDiscordRichPresence)); root.Add("enable_voice_control", JToken.FromObject(EnableVoiceControl)); + root.Add("enable_note_taking", JToken.FromObject(EnableNoteTaking)); + if (!string.IsNullOrWhiteSpace(VoiceInputDeviceName)) + root.Add("voice_input_device_name", JToken.FromObject(VoiceInputDeviceName)); root.Add("prompt_on_refresh_close", JToken.FromObject(PromptOnRefreshClose)); - root.Add("ignore_bonta_alttpr_multiworld_rom_check", JToken.FromObject(IgnoreBontaMultiWorldRomCheck)); - root.Add("multiworld_notification_level", JToken.FromObject(MultiworldNotificationLevel)); if (!string.IsNullOrWhiteSpace(ServiceBaseURL)) root.Add("service_base_url", JToken.FromObject(ServiceBaseURL)); @@ -342,6 +401,14 @@ private void WriteSettings() if (reposVal != null) root.Add("package_repositories", reposVal); + if (mProviderSettings.Count > 0) + { + var providerObj = new JObject(); + foreach (var kvp in mProviderSettings) + providerObj.Add(kvp.Key, JToken.FromObject(kvp.Value)); + root.Add("provider_settings", providerObj); + } + jsonWriter.WriteToken(root.CreateReader()); } } diff --git a/EmoTracker.Data/AutoTracking/AutoTrackingDeviceBase.cs b/EmoTracker.Data/AutoTracking/AutoTrackingDeviceBase.cs new file mode 100644 index 0000000..b8bdb5d --- /dev/null +++ b/EmoTracker.Data/AutoTracking/AutoTrackingDeviceBase.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace EmoTracker.Data.AutoTracking +{ + public abstract class AutoTrackingDeviceBase : IAutoTrackingDevice + { + public abstract string Id { get; } + public abstract string DisplayName { get; } + + public abstract Task ConnectAsync(); + public abstract Task DisconnectAsync(); + public abstract bool IsConnected { get; } + public abstract event EventHandler ConnectionStatusChanged; + + public abstract IReadOnlyList Options { get; } + public abstract IReadOnlyList Operations { get; } + + // Sync read — override in providers where sync is native + public virtual bool Read(ulong startAddress, byte[] buffer) + { + var result = ReadAsync(startAddress, buffer.Length).GetAwaiter().GetResult(); + if (result.success && result.data != null) + { + Buffer.BlockCopy(result.data, 0, buffer, 0, Math.Min(result.data.Length, buffer.Length)); + return true; + } + return false; + } + + public virtual bool Read8(ulong address, out byte value) + { + var result = Read8Async(address).GetAwaiter().GetResult(); + value = result.value; + return result.success; + } + + public virtual bool Read16(ulong address, out ushort value) + { + var result = Read16Async(address).GetAwaiter().GetResult(); + value = result.value; + return result.success; + } + + public virtual bool Read32(ulong address, out uint value) + { + var result = Read32Async(address).GetAwaiter().GetResult(); + value = result.value; + return result.success; + } + + public virtual bool Read64(ulong address, out ulong value) + { + var result = Read64Async(address).GetAwaiter().GetResult(); + value = result.value; + return result.success; + } + + // Sync write — override in providers where sync is native + public virtual bool Write(ulong startAddress, byte[] buffer) + { + return WriteAsync(startAddress, buffer).GetAwaiter().GetResult(); + } + + public virtual bool Write8(ulong address, byte value) + { + return Write8Async(address, value).GetAwaiter().GetResult(); + } + + public virtual bool Write16(ulong address, ushort value) + { + return Write16Async(address, value).GetAwaiter().GetResult(); + } + + public virtual bool Write32(ulong address, uint value) + { + return Write32Async(address, value).GetAwaiter().GetResult(); + } + + public virtual bool Write64(ulong address, ulong value) + { + return Write64Async(address, value).GetAwaiter().GetResult(); + } + + // Async read — override in providers where async is native (e.g., SNI gRPC) + public virtual Task<(bool success, byte[] data)> ReadAsync(ulong startAddress, int length) + { + byte[] buffer = new byte[length]; + bool success = Read(startAddress, buffer); + return Task.FromResult((success, success ? buffer : (byte[])null)); + } + + public virtual Task<(bool success, byte value)> Read8Async(ulong address) + { + bool success = Read8(address, out byte value); + return Task.FromResult((success, value)); + } + + public virtual Task<(bool success, ushort value)> Read16Async(ulong address) + { + bool success = Read16(address, out ushort value); + return Task.FromResult((success, value)); + } + + public virtual Task<(bool success, uint value)> Read32Async(ulong address) + { + bool success = Read32(address, out uint value); + return Task.FromResult((success, value)); + } + + public virtual Task<(bool success, ulong value)> Read64Async(ulong address) + { + bool success = Read64(address, out ulong value); + return Task.FromResult((success, value)); + } + + // Async write — override in providers where async is native + public virtual Task WriteAsync(ulong startAddress, byte[] buffer) + { + return Task.FromResult(Write(startAddress, buffer)); + } + + public virtual Task Write8Async(ulong address, byte value) + { + return Task.FromResult(Write8(address, value)); + } + + public virtual Task Write16Async(ulong address, ushort value) + { + return Task.FromResult(Write16(address, value)); + } + + public virtual Task Write32Async(ulong address, uint value) + { + return Task.FromResult(Write32(address, value)); + } + + public virtual Task Write64Async(ulong address, ulong value) + { + return Task.FromResult(Write64(address, value)); + } + + public virtual void Dispose() + { + if (IsConnected) + DisconnectAsync().GetAwaiter().GetResult(); + } + } +} diff --git a/EmoTracker.Data/AutoTracking/AutoTrackingProviderAttribute.cs b/EmoTracker.Data/AutoTracking/AutoTrackingProviderAttribute.cs new file mode 100644 index 0000000..e33f2c5 --- /dev/null +++ b/EmoTracker.Data/AutoTracking/AutoTrackingProviderAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace EmoTracker.Data.AutoTracking +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoTrackingProviderAttribute : Attribute + { + } +} diff --git a/EmoTracker.Data/AutoTracking/AutoTrackingProviderBase.cs b/EmoTracker.Data/AutoTracking/AutoTrackingProviderBase.cs new file mode 100644 index 0000000..d0ad3fa --- /dev/null +++ b/EmoTracker.Data/AutoTracking/AutoTrackingProviderBase.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using EmoTracker.Data.Packages; + +namespace EmoTracker.Data.AutoTracking +{ + public abstract class AutoTrackingProviderBase : IAutoTrackingProvider + { + public abstract string UID { get; } + public abstract string DisplayName { get; } + public abstract IReadOnlyList SupportedPlatforms { get; } + + public abstract Task RefreshDevicesAsync(); + public abstract IReadOnlyList AvailableDevices { get; } + + public abstract IAutoTrackingDevice DefaultDevice { get; set; } + + public abstract IReadOnlyList Options { get; } + public abstract IReadOnlyList Operations { get; } + + // Connection lifecycle — delegates to DefaultDevice + public virtual Task ConnectAsync() + { + return DefaultDevice?.ConnectAsync() ?? Task.CompletedTask; + } + + public virtual Task DisconnectAsync() + { + return DefaultDevice?.DisconnectAsync() ?? Task.CompletedTask; + } + + public virtual bool IsConnected => DefaultDevice?.IsConnected ?? false; + + public virtual event EventHandler ConnectionStatusChanged + { + add { if (DefaultDevice != null) DefaultDevice.ConnectionStatusChanged += value; } + remove { if (DefaultDevice != null) DefaultDevice.ConnectionStatusChanged -= value; } + } + + public virtual event EventHandler AvailableDevicesChanged { add { } remove { } } + + // Sync read — delegates to DefaultDevice + public virtual bool Read(ulong startAddress, byte[] buffer) + { + return DefaultDevice?.Read(startAddress, buffer) ?? false; + } + + public virtual bool Read8(ulong address, out byte value) + { + if (DefaultDevice != null) + return DefaultDevice.Read8(address, out value); + value = 0; + return false; + } + + public virtual bool Read16(ulong address, out ushort value) + { + if (DefaultDevice != null) + return DefaultDevice.Read16(address, out value); + value = 0; + return false; + } + + public virtual bool Read32(ulong address, out uint value) + { + if (DefaultDevice != null) + return DefaultDevice.Read32(address, out value); + value = 0; + return false; + } + + public virtual bool Read64(ulong address, out ulong value) + { + if (DefaultDevice != null) + return DefaultDevice.Read64(address, out value); + value = 0; + return false; + } + + // Sync write — delegates to DefaultDevice + public virtual bool Write(ulong startAddress, byte[] buffer) + { + return DefaultDevice?.Write(startAddress, buffer) ?? false; + } + + public virtual bool Write8(ulong address, byte value) + { + return DefaultDevice?.Write8(address, value) ?? false; + } + + public virtual bool Write16(ulong address, ushort value) + { + return DefaultDevice?.Write16(address, value) ?? false; + } + + public virtual bool Write32(ulong address, uint value) + { + return DefaultDevice?.Write32(address, value) ?? false; + } + + public virtual bool Write64(ulong address, ulong value) + { + return DefaultDevice?.Write64(address, value) ?? false; + } + + // Async read — delegates to DefaultDevice + public virtual Task<(bool success, byte[] data)> ReadAsync(ulong startAddress, int length) + { + return DefaultDevice?.ReadAsync(startAddress, length) ?? Task.FromResult<(bool, byte[])>((false, null)); + } + + public virtual Task<(bool success, byte value)> Read8Async(ulong address) + { + return DefaultDevice?.Read8Async(address) ?? Task.FromResult<(bool, byte)>((false, 0)); + } + + public virtual Task<(bool success, ushort value)> Read16Async(ulong address) + { + return DefaultDevice?.Read16Async(address) ?? Task.FromResult<(bool, ushort)>((false, 0)); + } + + public virtual Task<(bool success, uint value)> Read32Async(ulong address) + { + return DefaultDevice?.Read32Async(address) ?? Task.FromResult<(bool, uint)>((false, 0)); + } + + public virtual Task<(bool success, ulong value)> Read64Async(ulong address) + { + return DefaultDevice?.Read64Async(address) ?? Task.FromResult<(bool, ulong)>((false, 0)); + } + + // Async write — delegates to DefaultDevice + public virtual Task WriteAsync(ulong startAddress, byte[] buffer) + { + return DefaultDevice?.WriteAsync(startAddress, buffer) ?? Task.FromResult(false); + } + + public virtual Task Write8Async(ulong address, byte value) + { + return DefaultDevice?.Write8Async(address, value) ?? Task.FromResult(false); + } + + public virtual Task Write16Async(ulong address, ushort value) + { + return DefaultDevice?.Write16Async(address, value) ?? Task.FromResult(false); + } + + public virtual Task Write32Async(ulong address, uint value) + { + return DefaultDevice?.Write32Async(address, value) ?? Task.FromResult(false); + } + + public virtual Task Write64Async(ulong address, ulong value) + { + return DefaultDevice?.Write64Async(address, value) ?? Task.FromResult(false); + } + + public virtual void Dispose() + { + foreach (var device in AvailableDevices) + { + device.Dispose(); + } + } + } +} diff --git a/EmoTracker.Data/AutoTracking/AutoTrackingProviderRegistry.cs b/EmoTracker.Data/AutoTracking/AutoTrackingProviderRegistry.cs new file mode 100644 index 0000000..2c0a4a3 --- /dev/null +++ b/EmoTracker.Data/AutoTracking/AutoTrackingProviderRegistry.cs @@ -0,0 +1,36 @@ +using EmoTracker.Core; +using EmoTracker.Data.Packages; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EmoTracker.Data.AutoTracking +{ + public class AutoTrackingProviderRegistry : ObservableSingleton + { + public IEnumerable Providers => TypedObjectRegistry.SupportRegistry; + + public IAutoTrackingProvider FindByUID(string uid) + { + return Providers.FirstOrDefault(p => string.Equals(p.UID, uid, StringComparison.OrdinalIgnoreCase)); + } + + public IReadOnlyList GetProvidersForPack(IGamePackage pack) + { + if (pack == null) + return Array.Empty(); + + var manifestProviders = pack.AutoTrackerProviders; + if (manifestProviders != null && manifestProviders.Count > 0) + { + return Providers + .Where(p => manifestProviders.Any(uid => string.Equals(uid, p.UID, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + return Providers + .Where(p => p.SupportedPlatforms.Contains(pack.Platform)) + .ToList(); + } + } +} diff --git a/EmoTracker.Data/AutoTracking/IAutoTrackingDevice.cs b/EmoTracker.Data/AutoTracking/IAutoTrackingDevice.cs new file mode 100644 index 0000000..4568d77 --- /dev/null +++ b/EmoTracker.Data/AutoTracking/IAutoTrackingDevice.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace EmoTracker.Data.AutoTracking +{ + public interface IAutoTrackingDevice : IDisposable + { + string Id { get; } + string DisplayName { get; } + + // Connection lifecycle + Task ConnectAsync(); + Task DisconnectAsync(); + bool IsConnected { get; } + event EventHandler ConnectionStatusChanged; + + // Synchronous memory read + bool Read(ulong startAddress, byte[] buffer); + bool Read8(ulong address, out byte value); + bool Read16(ulong address, out ushort value); + bool Read32(ulong address, out uint value); + bool Read64(ulong address, out ulong value); + + // Synchronous memory write + bool Write(ulong startAddress, byte[] buffer); + bool Write8(ulong address, byte value); + bool Write16(ulong address, ushort value); + bool Write32(ulong address, uint value); + bool Write64(ulong address, ulong value); + + // Async memory read + Task<(bool success, byte[] data)> ReadAsync(ulong startAddress, int length); + Task<(bool success, byte value)> Read8Async(ulong address); + Task<(bool success, ushort value)> Read16Async(ulong address); + Task<(bool success, uint value)> Read32Async(ulong address); + Task<(bool success, ulong value)> Read64Async(ulong address); + + // Async memory write + Task WriteAsync(ulong startAddress, byte[] buffer); + Task Write8Async(ulong address, byte value); + Task Write16Async(ulong address, ushort value); + Task Write32Async(ulong address, uint value); + Task Write64Async(ulong address, ulong value); + + // Per-device options and operations + IReadOnlyList Options { get; } + IReadOnlyList Operations { get; } + } +} diff --git a/EmoTracker.Data/AutoTracking/IAutoTrackingProvider.cs b/EmoTracker.Data/AutoTracking/IAutoTrackingProvider.cs new file mode 100644 index 0000000..113ae97 --- /dev/null +++ b/EmoTracker.Data/AutoTracking/IAutoTrackingProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using EmoTracker.Data.Packages; + +namespace EmoTracker.Data.AutoTracking +{ + public interface IAutoTrackingProvider : IDisposable + { + string UID { get; } + string DisplayName { get; } + IReadOnlyList SupportedPlatforms { get; } + + // Device management + Task RefreshDevicesAsync(); + IReadOnlyList AvailableDevices { get; } + + // Default device — all provider-level read/write/connect calls delegate to this device + IAutoTrackingDevice DefaultDevice { get; set; } + + // Provider-level connection lifecycle (delegates to DefaultDevice) + Task ConnectAsync(); + Task DisconnectAsync(); + bool IsConnected { get; } + event EventHandler ConnectionStatusChanged; + + // Fired when AvailableDevices or DefaultDevice changes due to background scanning + event EventHandler AvailableDevicesChanged; + + // Provider-level synchronous memory read (delegates to DefaultDevice) + bool Read(ulong startAddress, byte[] buffer); + bool Read8(ulong address, out byte value); + bool Read16(ulong address, out ushort value); + bool Read32(ulong address, out uint value); + bool Read64(ulong address, out ulong value); + + // Provider-level synchronous memory write (delegates to DefaultDevice) + bool Write(ulong startAddress, byte[] buffer); + bool Write8(ulong address, byte value); + bool Write16(ulong address, ushort value); + bool Write32(ulong address, uint value); + bool Write64(ulong address, ulong value); + + // Provider-level async memory read (delegates to DefaultDevice) + Task<(bool success, byte[] data)> ReadAsync(ulong startAddress, int length); + Task<(bool success, byte value)> Read8Async(ulong address); + Task<(bool success, ushort value)> Read16Async(ulong address); + Task<(bool success, uint value)> Read32Async(ulong address); + Task<(bool success, ulong value)> Read64Async(ulong address); + + // Provider-level async memory write (delegates to DefaultDevice) + Task WriteAsync(ulong startAddress, byte[] buffer); + Task Write8Async(ulong address, byte value); + Task Write16Async(ulong address, ushort value); + Task Write32Async(ulong address, uint value); + Task Write64Async(ulong address, ulong value); + + // Provider-specific options and operations + IReadOnlyList Options { get; } + IReadOnlyList Operations { get; } + } +} diff --git a/EmoTracker.Data/AutoTracking/IProviderOperation.cs b/EmoTracker.Data/AutoTracking/IProviderOperation.cs new file mode 100644 index 0000000..85a4684 --- /dev/null +++ b/EmoTracker.Data/AutoTracking/IProviderOperation.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace EmoTracker.Data.AutoTracking +{ + public interface IProviderOperation + { + string Key { get; } + string DisplayName { get; } + bool CanExecute { get; } + Task ExecuteAsync(); + } +} diff --git a/EmoTracker.Data/AutoTracking/IProviderOption.cs b/EmoTracker.Data/AutoTracking/IProviderOption.cs new file mode 100644 index 0000000..acec96d --- /dev/null +++ b/EmoTracker.Data/AutoTracking/IProviderOption.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.ComponentModel; + +namespace EmoTracker.Data.AutoTracking +{ + public enum ProviderOptionKind + { + Dropdown, + Toggle + } + + public interface IProviderOption : INotifyPropertyChanged + { + string Key { get; } + string DisplayName { get; } + ProviderOptionKind Kind { get; } + object Value { get; set; } + IReadOnlyList AvailableValues { get; } + } +} diff --git a/EmoTracker.Data/AutoTracking/MemoryUpdateResult.cs b/EmoTracker.Data/AutoTracking/MemoryUpdateResult.cs new file mode 100644 index 0000000..032aabc --- /dev/null +++ b/EmoTracker.Data/AutoTracking/MemoryUpdateResult.cs @@ -0,0 +1,10 @@ +namespace EmoTracker.Data.AutoTracking +{ + public enum MemoryUpdateResult + { + Success, + Error, + MissingGameData, + InvalidAccess + } +} diff --git a/EmoTracker.Data/EmoTracker.Data.csproj b/EmoTracker.Data/EmoTracker.Data.csproj index c8d11b1..eebf262 100644 --- a/EmoTracker.Data/EmoTracker.Data.csproj +++ b/EmoTracker.Data/EmoTracker.Data.csproj @@ -5,14 +5,10 @@ Library EmoTracker.Data EmoTracker.Data - net472 + net8.0 false - - - - diff --git a/EmoTracker.Data/ItemDatabase.cs b/EmoTracker.Data/ItemDatabase.cs index 536e8d0..d1b2c77 100644 --- a/EmoTracker.Data/ItemDatabase.cs +++ b/EmoTracker.Data/ItemDatabase.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.Items; using EmoTracker.Data.JSON; using EmoTracker.Data.Locations; +using EmoTracker.Data.Scripting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -14,6 +15,14 @@ namespace EmoTracker.Data public class ItemDatabase : Singleton, ICodeProvider { ObservableCollection mItems = new ObservableCollection(); + Dictionary mItemIndex = new Dictionary(); + + // Code→provider index: maps lowercase code strings to lists of items that can provide them. + // LuaItems have dynamic code providers (Lua callbacks) and cannot be statically indexed, + // so they are kept in a separate list and brute-force checked as a fallback. + Dictionary> mCodeToProviders = new Dictionary>(StringComparer.OrdinalIgnoreCase); + List mDynamicCodeItems = new List(); + bool mCodeIndexBuilt = false; public IEnumerable Items { @@ -32,13 +41,64 @@ public void Reset() item.Dispose(); } - mItems.Clear(); + mItems.Clear(); + mItemIndex.Clear(); + mCodeToProviders.Clear(); + mDynamicCodeItems.Clear(); + mCodeIndexBuilt = false; + } + + /// + /// Builds the code→provider lookup index. Should be called once after all items are loaded. + /// Items that return null from GetAllProvidedCodes (e.g. LuaItem) are placed in the + /// dynamic fallback list and checked via brute-force on every query. + /// + public void BuildCodeIndex() + { + mCodeToProviders.Clear(); + mDynamicCodeItems.Clear(); + + foreach (var item in mItems) + { + if (item is ItemBase itemBase) + { + var codes = itemBase.GetAllProvidedCodes(); + if (codes == null) + { + // Dynamic code provider (e.g. LuaItem) — must be brute-force checked + mDynamicCodeItems.Add(item); + } + else + { + foreach (string code in codes) + { + string key = code; + if (!mCodeToProviders.TryGetValue(key, out var list)) + { + list = new List(); + mCodeToProviders[key] = list; + } + list.Add(item); + } + } + } + else + { + // Non-ItemBase implementors — treat as dynamic + mDynamicCodeItems.Add(item); + } + } + + mCodeIndexBuilt = true; } public void RegisterItem(ITrackableItem item) { - if (!mItems.Contains(item)) + if (!mItemIndex.ContainsKey(item)) + { + mItemIndex[item] = mItems.Count; mItems.Add(item); + } } public bool LegacyLoad(IGamePackage package) @@ -64,29 +124,29 @@ public bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = fa { try { - LocationDatabase.Instance.SuspendRefresh = true; - - using (StreamReader reader = new StreamReader(package.Open(path))) + using (new LocationDatabase.SuspendRefreshScope()) { - JArray items = (JArray)JToken.ReadFrom(new JsonTextReader(reader)); - foreach (JObject item in items) + using (StreamReader reader = new StreamReader(package.Open(path))) { - ITrackableItem instance = ItemBase.CreateItem(item, package); - if (instance != null) - mItems.Add(instance); + JArray items = (JArray)JToken.ReadFrom(new JsonTextReader(reader)); + foreach (JObject item in items) + { + ITrackableItem instance = ItemBase.CreateItem(item, package); + if (instance != null) + { + mItemIndex[instance] = mItems.Count; + mItems.Add(instance); + } + } } - } - bSuccess = true; + bSuccess = true; + } } catch (Exception e) { ScriptManager.Instance.OutputException(e); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } return bSuccess; @@ -94,6 +154,29 @@ public bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = fa internal bool CodeIsProvided(string code) { + if (mCodeIndexBuilt) + { + // Check indexed items + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + { + if (item.ProvidesCode(code) > 0) + return true; + } + } + + // Check dynamic items (LuaItems) + foreach (var item in mDynamicCodeItems) + { + if (item.ProvidesCode(code) > 0) + return true; + } + + return false; + } + + // Fallback: no index built yet foreach (ITrackableItem item in Items) { if (item.ProvidesCode(code) > 0) @@ -110,29 +193,65 @@ public object FindObjectForCode(string code) public uint ProviderCountForCode(string code, out AccessibilityLevel maxAccessibilityLevel) { - code = code.ToLower(); - // Item codes never constrain accessibility maxAccessibilityLevel = AccessibilityLevel.Normal; - uint nCount = 0; - foreach (ITrackableItem item in Items) + if (mCodeIndexBuilt) { - nCount += item.ProvidesCode(code); + uint nCount = 0; + + // Check indexed items first + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + nCount += item.ProvidesCode(code); + } + + // Check dynamic items (LuaItems) + foreach (var item in mDynamicCodeItems) + nCount += item.ProvidesCode(code); + + return nCount; } - return nCount; + // Fallback: no index built yet + { + uint nCount = 0; + foreach (ITrackableItem item in Items) + nCount += item.ProvidesCode(code); + return nCount; + } } internal ITrackableItem FindProvidingItemForCode(string code) { - if (!string.IsNullOrWhiteSpace(code)) + if (string.IsNullOrWhiteSpace(code)) + return null; + + if (mCodeIndexBuilt) { - foreach (ITrackableItem item in Items) + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + { + if (item.CanProvideCode(code)) + return item; + } + } + + foreach (var item in mDynamicCodeItems) { if (item.CanProvideCode(code)) return item; } + + return null; + } + + foreach (ITrackableItem item in Items) + { + if (item.CanProvideCode(code)) + return item; } return null; @@ -142,13 +261,33 @@ public ITrackableItem[] FindProvidingItemsForCode(string code) { List found = new List(); - if (!string.IsNullOrWhiteSpace(code)) + if (string.IsNullOrWhiteSpace(code)) + return found.ToArray(); + + if (mCodeIndexBuilt) { - foreach (ITrackableItem item in Items) + if (mCodeToProviders.TryGetValue(code, out var indexed)) + { + foreach (var item in indexed) + { + if (item.CanProvideCode(code)) + found.Add(item); + } + } + + foreach (var item in mDynamicCodeItems) { if (item.CanProvideCode(code)) found.Add(item); } + + return found.ToArray(); + } + + foreach (ITrackableItem item in Items) + { + if (item.CanProvideCode(code)) + found.Add(item); } return found.ToArray(); @@ -207,9 +346,7 @@ internal bool Load(JObject root) public string GetPersistableItemReference(ITrackableItem item, bool allowAnyType = false) { - int idx = mItems.IndexOf(item); - - if (idx < 0) + if (!mItemIndex.TryGetValue(item, out int idx)) throw new InvalidOperationException("Cannot generate persistable reference for item that is not in the ItemDatabase"); string jsonTypeTag = JsonTypeTagsAttribute.GetDefaultTagForType(item.GetType()); diff --git a/EmoTracker.Data/Items/BlankItem.cs b/EmoTracker.Data/Items/BlankItem.cs index f516342..bdaf329 100644 --- a/EmoTracker.Data/Items/BlankItem.cs +++ b/EmoTracker.Data/Items/BlankItem.cs @@ -1,5 +1,7 @@ using EmoTracker.Core; using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Linq; namespace EmoTracker.Data.Items { @@ -16,6 +18,8 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() => Enumerable.Empty(); + public override void OnLeftClick() { } diff --git a/EmoTracker.Data/Items/CompositeToggleItem.cs b/EmoTracker.Data/Items/CompositeToggleItem.cs index 3bdb17c..d39c7e8 100644 --- a/EmoTracker.Data/Items/CompositeToggleItem.cs +++ b/EmoTracker.Data/Items/CompositeToggleItem.cs @@ -29,6 +29,8 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() => mProvidedCodes.ProvidedCodes; + public override uint ProvidesCode(string code) { if (mProvidedCodes.ProvidesCode(code)) diff --git a/EmoTracker.Data/Items/ConsumableItem.cs b/EmoTracker.Data/Items/ConsumableItem.cs index a1a7422..1fca36b 100644 --- a/EmoTracker.Data/Items/ConsumableItem.cs +++ b/EmoTracker.Data/Items/ConsumableItem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Newtonsoft.Json.Linq; using EmoTracker.Core; using EmoTracker.Data.JSON; @@ -115,6 +116,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override uint ProvidesCode(string code) { if (AvailableCount > 0 && mCodeProvider.ProvidesCode(code)) diff --git a/EmoTracker.Data/Items/ItemBase.cs b/EmoTracker.Data/Items/ItemBase.cs index 989000d..87aa30b 100644 --- a/EmoTracker.Data/Items/ItemBase.cs +++ b/EmoTracker.Data/Items/ItemBase.cs @@ -3,6 +3,8 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Linq; namespace EmoTracker.Data.Items { @@ -60,6 +62,12 @@ public bool IgnoreUserInput set { SetProperty(ref mbIgnoreUserInput, value); } } + public string[] PhoneticSubstitutes + { + get { return mPhoneticSubstitutes; } + set { SetProperty(ref mPhoneticSubstitutes, value); } + } + [DependentProperty("PotentialIcon")] public ImageReference Icon { @@ -99,6 +107,13 @@ public void InvalidateAccessibility() public abstract bool CanProvideCode(string code); public abstract void AdvanceToCode(string code = null); + /// + /// Returns the set of all codes this item can potentially provide, for indexing purposes. + /// Returns null if the item's codes are dynamic and cannot be statically enumerated + /// (e.g. LuaItem with a Lua callback). + /// + public virtual IEnumerable GetAllProvidedCodes() => null; + #region -- Static Methods --- @@ -114,6 +129,10 @@ public static ITrackableItem CreateItem(JObject data, IGamePackage package) instance.IgnoreUserInput = data.GetValue("ignore_user_input", false); instance.DisabledImageFilterSpec = data.GetValue("disabled_image_filter", null); + var phonetics = data["phonetic_substitutes"] as Newtonsoft.Json.Linq.JArray; + if (phonetics != null) + instance.PhoneticSubstitutes = phonetics.Values().Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); + instance.ParseDataInternal(data, package); } @@ -156,6 +175,7 @@ bool ITrackableItem.Load(JObject data) string mDisabledImageFilterSpec; string mBadgeText; string mBadgeTextColor = "WhiteSmoke"; + string[] mPhoneticSubstitutes; bool mbCapturable = true; bool mbMaskInput = false; bool mbIgnoreUserInput = false; diff --git a/EmoTracker.Data/Items/ProgressiveItem.cs b/EmoTracker.Data/Items/ProgressiveItem.cs index 2a8a288..997a5d7 100644 --- a/EmoTracker.Data/Items/ProgressiveItem.cs +++ b/EmoTracker.Data/Items/ProgressiveItem.cs @@ -111,6 +111,17 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() + { + var codes = new HashSet(); + foreach (Stage stage in StagesInternal) + { + foreach (string code in stage.ProvidedCodes) + codes.Add(code); + } + return codes; + } + public override uint ProvidesCode(string code) { if (CurrentStageInstance != null) diff --git a/EmoTracker.Data/Items/ProgressiveToggleItem.cs b/EmoTracker.Data/Items/ProgressiveToggleItem.cs index b928e93..1ec4a00 100644 --- a/EmoTracker.Data/Items/ProgressiveToggleItem.cs +++ b/EmoTracker.Data/Items/ProgressiveToggleItem.cs @@ -79,6 +79,20 @@ public override bool CanProvideCode(string code) return false; } + public override IEnumerable GetAllProvidedCodes() + { + var codes = new HashSet(); + foreach (Stage stage in mStages.Values) + { + if (stage != null) + { + foreach (string code in stage.ProvidedCodes) + codes.Add(code); + } + } + return codes; + } + public override uint ProvidesCode(string code) { Stage stageDef; diff --git a/EmoTracker.Data/Items/SectionChestsProxyItem.cs b/EmoTracker.Data/Items/SectionChestsProxyItem.cs index 296e2d5..b60e9af 100644 --- a/EmoTracker.Data/Items/SectionChestsProxyItem.cs +++ b/EmoTracker.Data/Items/SectionChestsProxyItem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using EmoTracker.Core; using EmoTracker.Data.JSON; using EmoTracker.Data.Locations; @@ -81,6 +82,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void OnLeftClick() { if (AllowManipulation && Section.AvailableChestCount > 0) diff --git a/EmoTracker.Data/Items/StaticItem.cs b/EmoTracker.Data/Items/StaticItem.cs index 0f82242..070dd13 100644 --- a/EmoTracker.Data/Items/StaticItem.cs +++ b/EmoTracker.Data/Items/StaticItem.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace EmoTracker.Data.Items { @@ -20,6 +21,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void OnLeftClick() { } diff --git a/EmoTracker.Data/Items/ToggleBadgedItem.cs b/EmoTracker.Data/Items/ToggleBadgedItem.cs index e1a1f3b..a1707ce 100644 --- a/EmoTracker.Data/Items/ToggleBadgedItem.cs +++ b/EmoTracker.Data/Items/ToggleBadgedItem.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace EmoTracker.Data.Items { @@ -85,6 +86,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void AdvanceToCode(string code = null) { Active = true; diff --git a/EmoTracker.Data/Items/ToggleItem.cs b/EmoTracker.Data/Items/ToggleItem.cs index d1d3b63..258effa 100644 --- a/EmoTracker.Data/Items/ToggleItem.cs +++ b/EmoTracker.Data/Items/ToggleItem.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace EmoTracker.Data.Items { @@ -106,6 +107,8 @@ public override bool CanProvideCode(string code) return mCodeProvider.ProvidesCode(code); } + public override IEnumerable GetAllProvidedCodes() => mCodeProvider.ProvidedCodes; + public override void AdvanceToCode(string code = null) { Active = true; diff --git a/EmoTracker.Data/Layout/LayoutItem.cs b/EmoTracker.Data/Layout/LayoutItem.cs index 2cccae4..1f83625 100644 --- a/EmoTracker.Data/Layout/LayoutItem.cs +++ b/EmoTracker.Data/Layout/LayoutItem.cs @@ -154,6 +154,11 @@ public bool OverrideScale get { return mScale > 0.0; } } + public double EffectiveScale + { + get { return OverrideScale ? mScale : 1.0; } + } + public bool OverrideCanvasX { get { return mCanvasX > 0.0; } @@ -190,7 +195,7 @@ public string DockLocation public double Scale { get { return mScale; } - protected set { SetProperty(ref mScale, value); NotifyPropertyChanged("OverrideScale"); } + protected set { SetProperty(ref mScale, value); NotifyPropertyChanged("OverrideScale"); NotifyPropertyChanged("EffectiveScale"); } } public double Width diff --git a/EmoTracker.Data/Layout/TextBlock.cs b/EmoTracker.Data/Layout/TextBlock.cs index 7ef5e35..f30b16c 100644 --- a/EmoTracker.Data/Layout/TextBlock.cs +++ b/EmoTracker.Data/Layout/TextBlock.cs @@ -10,6 +10,7 @@ namespace EmoTracker.Data.Layout public class TextBlock : LayoutItem { string mText; + double mFontSize = -1.0; public string Text { @@ -17,9 +18,20 @@ public string Text set { SetProperty(ref mText, value); } } + /// + /// Font size for the text element, in points. A value of -1.0 (the default) means + /// "not set" — the rendered control will inherit the font size from the visual tree. + /// + public double FontSize + { + get { return mFontSize; } + set { SetProperty(ref mFontSize, value); } + } + protected override bool TryParseInternal(JObject data, IGamePackage package) { Text = data.GetValue("text"); + FontSize = data.GetValue("font_size", -1.0); return true; } } diff --git a/EmoTracker.Data/LocationDatabase.cs b/EmoTracker.Data/LocationDatabase.cs index 0c04667..a6f8865 100644 --- a/EmoTracker.Data/LocationDatabase.cs +++ b/EmoTracker.Data/LocationDatabase.cs @@ -16,17 +16,14 @@ public class LocationDatabase : ObservableSingleton, ICodeProv { public class SuspendRefreshScope : IDisposable { - bool mbSuspend; - public SuspendRefreshScope() { - mbSuspend = LocationDatabase.Instance.SuspendRefresh; - LocationDatabase.Instance.SuspendRefresh = true; + LocationDatabase.Instance.PushSuspendRefresh(); } public virtual void Dispose() { - LocationDatabase.Instance.SuspendRefresh = mbSuspend; + LocationDatabase.Instance.PopSuspendRefresh(); } } @@ -34,18 +31,43 @@ public virtual void Dispose() Location mLastClearedLocation; ObservableCollection mAllLocations = new ObservableCollection(); + Dictionary mLocationIndex = new Dictionary(); ObservableCollection mPinnedLocations = new ObservableCollection(); ObservableCollection mVisibleLocations = new ObservableCollection(); public bool SuspendRefresh { - get { return mbSuspendRefresh; } + get { return mSuspendRefreshCount > 0; } set { - if (SetProperty(ref mbSuspendRefresh, value) && !mbSuspendRefresh) - { - RefeshAccessibility(bPendingOnly: true); - } + // Legacy compatibility: direct assignment is discouraged. + // Prefer SuspendRefreshScope for reentrant-safe scoping. + if (value) + PushSuspendRefresh(); + else + PopSuspendRefresh(); + } + } + + internal void PushSuspendRefresh() + { + ++mSuspendRefreshCount; + } + + internal void PopSuspendRefresh() + { + if (mSuspendRefreshCount <= 0) + { + ScriptManager.Instance.OutputError("PopSuspendRefresh called with no matching Push — possible over-close bug"); + System.Diagnostics.Debug.Fail("PopSuspendRefresh: underflow — more Pops than Pushes"); + return; + } + + --mSuspendRefreshCount; + + if (mSuspendRefreshCount == 0) + { + RefeshAccessibility(bPendingOnly: true); } } @@ -84,6 +106,7 @@ public void Reset() { LastClearedLocation = null; mAllLocations.Clear(); + mLocationIndex.Clear(); mPinnedLocations.Clear(); mVisibleLocations.Clear(); mRoot = new Location() @@ -141,7 +164,7 @@ internal bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = { try { - mbSuspendRefresh = true; + PushSuspendRefresh(); using (Stream s = package.Open(path)) { @@ -170,7 +193,7 @@ internal bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = } finally { - mbSuspendRefresh = false; + PopSuspendRefresh(); } RefeshAccessibility(); @@ -279,13 +302,13 @@ public void UnpinLocation(Location location) mPinnedLocations.Remove(location); } - bool mbSuspendRefresh = false; + int mSuspendRefreshCount = 0; bool mbInRefresh = false; uint mPendingRefreshCount = 0; internal void RefeshAccessibility(bool bPendingOnly = false) { - if (!mbSuspendRefresh) + if (mSuspendRefreshCount == 0) { if (!bPendingOnly) ++mPendingRefreshCount; @@ -298,27 +321,34 @@ internal void RefeshAccessibility(bool bPendingOnly = false) { mbInRefresh = true; - while (mPendingRefreshCount > 0) + using (ObservableObject.SuspendNotifications()) { - mPendingRefreshCount = 0; - bRefreshedAccessibility = true; + while (mPendingRefreshCount > 0) + { + mPendingRefreshCount = 0; + bRefreshedAccessibility = true; - AccessibilityRule.ClearCaches(); - ScriptManager.Instance.ClearExpressionCache(); + AccessibilityRule.ClearCaches(); + ScriptManager.Instance.ClearExpressionCache(); - ScriptManager.Instance.InvokeStandardCallback(ScriptManager.StandardCallback.AccessibilityUpdating); + ScriptManager.Instance.InvokeStandardCallback(ScriptManager.StandardCallback.AccessibilityUpdating); - if (mRoot != null) - mRoot.RefreshAccessibility(); + if (mRoot != null) + mRoot.RefreshAccessibility(); - MapDatabase.Instance.MarkVisibilityDirty(); - } + MapDatabase.Instance.MarkVisibilityDirty(); + } + } // queued PropertyChanged notifications fire here, before AccessibilityUpdated } finally { mbInRefresh = false; - AccessibilityRule.ClearCaches(); + // Do NOT clear caches here — they were built during RefreshAccessibility() + // above and must survive into the AccessibilityUpdated callback so that + // any rule evaluations triggered by that callback benefit from the cache. + // Clearing here negated all caching, reproducing the slow-update symptom + // that enable_accessibility_rule_caching was introduced to fix. MapDatabase.Instance.UpdateVisibilityIfNecessary(); if (bRefreshedAccessibility) @@ -518,6 +548,7 @@ Location LoadLocation(IGamePackage package, Location parent, JObject data) } } + mLocationIndex[instance] = mAllLocations.Count; mAllLocations.Add(instance); var children = data.GetValue("children"); @@ -588,10 +619,9 @@ internal void Save(JObject root) internal bool Load(JObject root) { + PushSuspendRefresh(); try { - SuspendRefresh = true; - JObject locationDatabaseData = root.GetValue("location_database"); if (locationDatabaseData == null) return true; @@ -658,15 +688,13 @@ internal bool Load(JObject root) } finally { - SuspendRefresh = false; + PopSuspendRefresh(); } } public string GetPersistableLocationReference(Location location) { - int idx = mAllLocations.IndexOf(location); - - if (idx < 0) + if (!mLocationIndex.TryGetValue(location, out int idx)) throw new InvalidOperationException("Cannot generate persistable reference for location that is not in the LocationDatabase"); if (!string.IsNullOrWhiteSpace(location.Name)) diff --git a/EmoTracker.Data/Locations/Map.cs b/EmoTracker.Data/Locations/Map.cs index 7f4d6e5..2cbaebd 100644 --- a/EmoTracker.Data/Locations/Map.cs +++ b/EmoTracker.Data/Locations/Map.cs @@ -131,7 +131,10 @@ public double Size private void UpdateBadgeMargin() { - mBadgeMargin = new Thickness((mSize * 0.5) + (mBadgeSize * -0.5), (mSize * 0.5) + (mBadgeSize * -0.5), 0, 0); + // Centre the badge on the location dot: offset the badge's top-left by + // (dotCentre - badgeSize/2) so the badge is symmetrically overlaid on the dot. + double offset = (mSize - mBadgeSize) * 0.5; + mBadgeMargin = new Thickness(offset, offset, 0, 0); NotifyPropertyChanged("BadgeMargin"); } diff --git a/EmoTracker.Data/Media/ConcreteImageReference.cs b/EmoTracker.Data/Media/ConcreteImageReference.cs index 4bee14f..13d0779 100644 --- a/EmoTracker.Data/Media/ConcreteImageReference.cs +++ b/EmoTracker.Data/Media/ConcreteImageReference.cs @@ -1,9 +1,5 @@ using EmoTracker.Core; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace EmoTracker.Data.Media { @@ -22,5 +18,13 @@ public string Filter get { return mFilter; } set { SetProperty(ref mFilter, value); } } + + public override bool Equals(object obj) + => obj is ConcreteImageReference other + && Equals(mURI, other.mURI) + && mFilter == other.mFilter; + + public override int GetHashCode() + => HashCode.Combine(mURI, mFilter); } } diff --git a/EmoTracker.Data/Media/FilterImageReference.cs b/EmoTracker.Data/Media/FilterImageReference.cs index a9dbb70..5aee109 100644 --- a/EmoTracker.Data/Media/FilterImageReference.cs +++ b/EmoTracker.Data/Media/FilterImageReference.cs @@ -1,4 +1,6 @@ -namespace EmoTracker.Data.Media +using System; + +namespace EmoTracker.Data.Media { public class FilterImageReference : ImageReference { @@ -15,5 +17,13 @@ public string Filter get { return mFilter; } set { SetProperty(ref mFilter, value); } } + + public override bool Equals(object obj) + => obj is FilterImageReference other + && Equals(mReference, other.mReference) + && mFilter == other.mFilter; + + public override int GetHashCode() + => HashCode.Combine(mReference, mFilter); } } diff --git a/EmoTracker.Data/Media/ImageReference.cs b/EmoTracker.Data/Media/ImageReference.cs index 07c8221..c54f863 100644 --- a/EmoTracker.Data/Media/ImageReference.cs +++ b/EmoTracker.Data/Media/ImageReference.cs @@ -1,76 +1,324 @@ -using EmoTracker.Core; -using System; - -namespace EmoTracker.Data.Media -{ - public abstract class ImageReference : ObservableObject - { - public static ImageReference FromPackRelativePath(string path, string filter = null) - { - return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); - } - - public static ImageReference FromPackRelativePath(IGamePackage package, string path, string filter = null) - { - if (string.IsNullOrWhiteSpace(path)) - return null; - - path = path.Trim(); - path = path.TrimStart(',', '/', '\\'); - - if (package == null || !package.Exists(path)) - return null; - - if (!path.StartsWith("gamepackage://")) - { - path = "gamepackage://" + path; - } - - return new ConcreteImageReference() - { - URI = new Uri(path), - Filter = filter - }; - } - - public static ImageReference FromImageReference(ImageReference existingReference, string filter = null) - { - if (existingReference == null) - return null; - - if (string.IsNullOrWhiteSpace(filter)) - return existingReference; - - return new FilterImageReference() - { - Reference = existingReference, - Filter = filter - }; - } - - public static ImageReference FromExternalURI(Uri uri, string filter = null) - { - return new ConcreteImageReference() - { - URI = uri, - Filter = filter - }; - } - - public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) - { - LayeredImageReference instance = new LayeredImageReference(); - - foreach (ImageReference layer in layers) - { - if (layer != null) - instance.Layers.Add(layer); - } - - if (instance.Layers.Count > 0) - return instance; - - return null; - } - } -} +using EmoTracker.Core; +using System; +using System.Collections.Generic; +using System.IO; + +namespace EmoTracker.Data.Media +{ + public abstract class ImageReference : ObservableObject + { + /// + /// The resolved display-ready image for this reference. Set by the image + /// resolution service on the UI thread once background generation completes. + /// XAML bindings should bind to Icon.ResolvedImage (etc.) so that the + /// UI updates automatically when the image becomes available. + /// + object mResolvedImage; + public object ResolvedImage + { + get { return mResolvedImage; } + set { SetProperty(ref mResolvedImage, value); } + } + + /// + /// Source image width in pixels, read from the image header at creation time. + /// Used to create correctly-sized placeholder images so Avalonia's layout + /// system can measure controls before the real image is resolved. + /// Zero if dimensions could not be determined. + /// + public int SourceWidth { get; set; } + + /// + /// Source image height in pixels. See . + /// + public int SourceHeight { get; set; } + + /// + /// Optional callback invoked whenever a new ImageReference is created via + /// a factory method. Set by the image resolution service at startup so + /// that newly-created references are automatically queued for background + /// resolution. + /// + public static Action OnImageReferenceCreated { get; set; } + + static void NotifyCreated(ImageReference imageRef) + { + OnImageReferenceCreated?.Invoke(imageRef); + } + + /// + /// Reads the width and height from a PNG or BMP image stream header + /// without fully decoding the image. Returns (0, 0) if the format + /// is not recognised or the stream is too short. + /// + internal static (int width, int height) ReadImageDimensions(Stream stream) + { + if (stream == null || !stream.CanRead) + return (0, 0); + + try + { + byte[] header = new byte[26]; + int bytesRead = 0; + while (bytesRead < header.Length) + { + int n = stream.Read(header, bytesRead, header.Length - bytesRead); + if (n == 0) break; + bytesRead += n; + } + + // PNG: signature(8) + IHDR length(4) + "IHDR"(4) + width(4) + height(4) + if (bytesRead >= 24 && + header[0] == 137 && header[1] == 80 && header[2] == 78 && header[3] == 71) + { + int w = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; + int h = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; + return (w, h); + } + + // BMP: "BM" + filesize(4) + reserved(4) + offset(4) + headersize(4) + width(4) + height(4) + if (bytesRead >= 26 && header[0] == (byte)'B' && header[1] == (byte)'M') + { + int w = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); + int h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); + if (h < 0) h = -h; // top-down BMP uses negative height + return (w, h); + } + + // JPEG: SOI marker (0xFF 0xD8), then scan for SOF0/SOF2 frame marker + if (bytesRead >= 2 && header[0] == 0xFF && header[1] == 0xD8) + { + return ReadJpegDimensions(stream, header, bytesRead); + } + } + catch + { + // Swallow – dimensions are best-effort + } + + return (0, 0); + } + + /// + /// Scans JPEG markers to find a Start-Of-Frame (SOF) segment and reads + /// the image dimensions from it. The stream position is after the initial + /// header bytes that were already read into . + /// + static (int width, int height) ReadJpegDimensions(Stream stream, byte[] header, int bytesRead) + { + // We've consumed the first 'bytesRead' bytes into header[]. + // Continue reading the marker stream from the current position. + // JPEG markers are 0xFF followed by a marker type byte. + // SOF markers: 0xC0 (baseline), 0xC1 (extended), 0xC2 (progressive). + // SOF payload: length(2) + precision(1) + height(2) + width(2) + + // First, process any remaining bytes in the header buffer starting + // after the 2-byte SOI marker. + int pos = 2; + byte[] buf = new byte[2]; + + while (true) + { + // Read marker: 0xFF + type + int b1, b2; + + if (pos + 1 < bytesRead) + { + b1 = header[pos]; + b2 = header[pos + 1]; + pos += 2; + } + else + { + b1 = stream.ReadByte(); + b2 = stream.ReadByte(); + } + + if (b1 < 0 || b2 < 0) + return (0, 0); // unexpected end of stream + + if (b1 != 0xFF) + return (0, 0); // not a valid marker + + // Skip padding 0xFF bytes + while (b2 == 0xFF) + { + b2 = stream.ReadByte(); + if (b2 < 0) return (0, 0); + } + + // SOF0 (0xC0), SOF1 (0xC1), SOF2 (0xC2) contain dimensions + if (b2 >= 0xC0 && b2 <= 0xC2) + { + // Read: length(2) + precision(1) + height(2) + width(2) + byte[] sof = new byte[7]; + int sofRead = 0; + while (sofRead < 7) + { + int n = stream.Read(sof, sofRead, 7 - sofRead); + if (n == 0) return (0, 0); + sofRead += n; + } + + int height = (sof[3] << 8) | sof[4]; + int width = (sof[5] << 8) | sof[6]; + return (width, height); + } + + // Not a SOF marker – skip this segment + // Read segment length (2 bytes, big-endian, includes the length bytes) + int len1 = stream.ReadByte(); + int len2 = stream.ReadByte(); + if (len1 < 0 || len2 < 0) return (0, 0); + + int segLen = (len1 << 8) | len2; + if (segLen < 2) return (0, 0); + + // Skip the rest of the segment + int toSkip = segLen - 2; + if (stream.CanSeek) + { + stream.Position += toSkip; + } + else + { + byte[] skipBuf = new byte[Math.Min(toSkip, 4096)]; + while (toSkip > 0) + { + int n = stream.Read(skipBuf, 0, Math.Min(toSkip, skipBuf.Length)); + if (n == 0) return (0, 0); + toSkip -= n; + } + } + + // Safety: don't scan forever + if (stream.CanSeek && stream.Position > 65536) + return (0, 0); + } + } + + /// + /// Reads image dimensions from the package for the given path and stores + /// them on the reference. The stream is opened, header is read, and + /// the stream is disposed immediately. + /// + static void PopulateDimensions(ImageReference result, IGamePackage package, string rawPath) + { + try + { + // rawPath is the gamepackage:// URI – extract the actual file path + string filePath = rawPath; + if (filePath.StartsWith("gamepackage://")) + filePath = filePath.Substring("gamepackage://".Length); + + using (Stream s = package.Open(filePath)) + { + if (s != null) + { + var (w, h) = ReadImageDimensions(s); + result.SourceWidth = w; + result.SourceHeight = h; + } + } + } + catch + { + // Dimensions are best-effort; leave as 0×0 + } + } + + public static ImageReference FromPackRelativePath(string path, string filter = null) + { + return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); + } + + public static ImageReference FromPackRelativePath(IGamePackage package, string path, string filter = null) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + path = path.Trim(); + path = path.TrimStart(',', '/', '\\'); + + if (package == null || !package.Exists(path)) + return null; + + if (!path.StartsWith("gamepackage://")) + { + path = "gamepackage://" + path; + } + + var result = new ConcreteImageReference() + { + URI = new Uri(path), + Filter = filter + }; + + PopulateDimensions(result, package, path); + NotifyCreated(result); + + return result; + } + + public static ImageReference FromImageReference(ImageReference existingReference, string filter = null) + { + if (existingReference == null) + return null; + + if (string.IsNullOrWhiteSpace(filter)) + return existingReference; + + var result = new FilterImageReference() + { + Reference = existingReference, + Filter = filter + }; + + // Filters don't change dimensions – inherit from the source + result.SourceWidth = existingReference.SourceWidth; + result.SourceHeight = existingReference.SourceHeight; + + NotifyCreated(result); + + return result; + } + + public static ImageReference FromExternalURI(Uri uri, string filter = null) + { + var result = new ConcreteImageReference() + { + URI = uri, + Filter = filter + }; + + NotifyCreated(result); + + return result; + } + + public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) + { + LayeredImageReference instance = new LayeredImageReference(); + + foreach (ImageReference layer in layers) + { + if (layer != null) + instance.Layers.Add(layer); + } + + if (instance.Layers.Count > 0) + { + // Layered images composite at the first layer's dimensions + var firstLayer = instance.Layers[0]; + instance.SourceWidth = firstLayer.SourceWidth; + instance.SourceHeight = firstLayer.SourceHeight; + + NotifyCreated(instance); + + return instance; + } + + return null; + } + } +} diff --git a/EmoTracker.Data/Media/LayeredImageReference.cs b/EmoTracker.Data/Media/LayeredImageReference.cs index 7113755..01739e4 100644 --- a/EmoTracker.Data/Media/LayeredImageReference.cs +++ b/EmoTracker.Data/Media/LayeredImageReference.cs @@ -1,10 +1,6 @@ -using EmoTracker.Core; -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace EmoTracker.Data.Media { @@ -16,5 +12,17 @@ public IList Layers { get { return mLayers; } } + + public override bool Equals(object obj) + => obj is LayeredImageReference other + && mLayers.SequenceEqual(other.mLayers); + + public override int GetHashCode() + { + var hc = new System.HashCode(); + foreach (var layer in mLayers) + hc.Add(layer); + return hc.ToHashCode(); + } } } diff --git a/EmoTracker.Data/Packages/GamePackage.cs b/EmoTracker.Data/Packages/GamePackage.cs index 4e5abf9..bb324a8 100644 --- a/EmoTracker.Data/Packages/GamePackage.cs +++ b/EmoTracker.Data/Packages/GamePackage.cs @@ -68,6 +68,7 @@ public IGamePackage Package Version mLayoutEngineVersion; GamePlatform mPlatform; bool mUnsafe = true; + List mAutoTrackerProviders = new List(); ObservableCollection mAvailableVariants = new ObservableCollection(); Variant mActiveVariant; @@ -138,6 +139,8 @@ public bool IsActive public bool FlaggedAsUnsafe { get { return mUnsafe; } } + public IReadOnlyList AutoTrackerProviders { get { return mAutoTrackerProviders; } } + string IGamePackage.OverridePath { get { return OverridePath; } } [DependentProperty("VariantOverridePath")] @@ -298,6 +301,17 @@ void LoadManifest() Version.TryParse(manifest.GetValue("layout_engine_version"), out mLayoutEngineVersion); + JArray autoTrackerProviders = manifest.GetValue("auto_tracker_providers"); + if (autoTrackerProviders != null) + { + foreach (var item in autoTrackerProviders) + { + string providerUid = item.Value(); + if (!string.IsNullOrWhiteSpace(providerUid)) + mAutoTrackerProviders.Add(providerUid); + } + } + JObject variantDefs = manifest.GetValue("variants"); if (variantDefs != null) { diff --git a/EmoTracker.Data/Packages/PackageManager.cs b/EmoTracker.Data/Packages/PackageManager.cs index 0857bc2..2e29b2e 100644 --- a/EmoTracker.Data/Packages/PackageManager.cs +++ b/EmoTracker.Data/Packages/PackageManager.cs @@ -1,4 +1,5 @@ -using EmoTracker.Core; +#pragma warning disable SYSLIB0014 // WebClient is obsolete +using EmoTracker.Core; using EmoTracker.Data.JSON; using EmoTracker.Data.Media; using Newtonsoft.Json; @@ -7,6 +8,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Net; namespace EmoTracker.Data.Packages @@ -268,11 +270,11 @@ public bool UpdatesAvailable { get { - foreach (PackageRepository repository in Repositories) + foreach (PackageRepository repository in Repositories.ToList()) { - foreach (PackageRepositoryEntry entry in repository.Packages) + foreach (PackageRepositoryEntry entry in repository.Packages.ToList()) { - if (entry.Status == PackageRepositoryEntry.PackageStatus.UpdateAvailable) + if (entry != null && entry.Status == PackageRepositoryEntry.PackageStatus.UpdateAvailable) return true; } } @@ -285,11 +287,11 @@ public bool CurrentPackageHasUpdateAvailable { get { - foreach (PackageRepository repository in Repositories) + foreach (PackageRepository repository in Repositories.ToList()) { - foreach (PackageRepositoryEntry entry in repository.Packages) + foreach (PackageRepositoryEntry entry in repository.Packages.ToList()) { - if (entry.Status == PackageRepositoryEntry.PackageStatus.UpdateAvailable) + if (entry != null && entry.Status == PackageRepositoryEntry.PackageStatus.UpdateAvailable) { if (entry.ExistingPackage == Tracker.Instance.ActiveGamePackage) return true; diff --git a/EmoTracker.Data/Packages/PackageRepository.cs b/EmoTracker.Data/Packages/PackageRepository.cs index 75fee6a..8b3408e 100644 --- a/EmoTracker.Data/Packages/PackageRepository.cs +++ b/EmoTracker.Data/Packages/PackageRepository.cs @@ -1,4 +1,5 @@ -using EmoTracker.Core; +#pragma warning disable SYSLIB0014 // WebClient is obsolete +using EmoTracker.Core; using EmoTracker.Data.JSON; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -567,8 +568,6 @@ private void MWebClient_DownloadDataCompleted(object sender, DownloadDataComplet !string.IsNullOrWhiteSpace(instance.URL)) { mPackages.Add(instance); - PackageManager.Instance.ForceRefreshProperty("UpdatesAvailable"); - PackageManager.Instance.ForceRefreshProperty("CurrentPackageHasUpdateAvailable"); } } } @@ -578,6 +577,8 @@ private void MWebClient_DownloadDataCompleted(object sender, DownloadDataComplet } DownloadStatus = DownloadStatus.Complete; + PackageManager.Instance.ForceRefreshProperty("UpdatesAvailable"); + PackageManager.Instance.ForceRefreshProperty("CurrentPackageHasUpdateAvailable"); } catch { diff --git a/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs b/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs index 83a62f5..f25ed88 100644 --- a/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs +++ b/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs @@ -7,6 +7,7 @@ namespace EmoTracker.Data.Packages public class ZipPackageSource : IGamePackageSource { private ZipArchive mArchive; + private readonly object mArchiveLock = new object(); private string mPath; public string ArchivePath @@ -22,10 +23,13 @@ public IEnumerable Files if (mArchive != null) { - foreach (ZipArchiveEntry entry in mArchive.Entries) + lock (mArchiveLock) { - if (!string.IsNullOrWhiteSpace(entry.FullName) && !entry.FullName.EndsWith("/")) - files.Add(entry.FullName); + foreach (ZipArchiveEntry entry in mArchive.Entries) + { + if (!string.IsNullOrWhiteSpace(entry.FullName) && !entry.FullName.EndsWith("/")) + files.Add(entry.FullName); + } } files.Sort(FileListSort); @@ -61,15 +65,22 @@ public Stream Open(string path) { if (mArchive != null && !string.IsNullOrWhiteSpace(path)) { - ZipArchiveEntry entry = mArchive.GetEntry(path); - if (entry != null) + // ZipArchive is NOT thread-safe for concurrent reads. The async + // image pre-cache worker resolves images on a background thread + // while the UI thread may also open streams (e.g. PopulateDimensions + // during pack load). Serialize all access to prevent corruption. + lock (mArchiveLock) { - using (Stream src = entry.Open()) + ZipArchiveEntry entry = mArchive.GetEntry(path); + if (entry != null) { - MemoryStream stream = new MemoryStream(); - src.CopyTo(stream); - stream.Seek(0, SeekOrigin.Begin); - return stream; + using (Stream src = entry.Open()) + { + MemoryStream stream = new MemoryStream(); + src.CopyTo(stream); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } } } } diff --git a/EmoTracker.Data/Properties/AssemblyInfo.cs b/EmoTracker.Data/Properties/AssemblyInfo.cs index fa8dac4..256816e 100644 --- a/EmoTracker.Data/Properties/AssemblyInfo.cs +++ b/EmoTracker.Data/Properties/AssemblyInfo.cs @@ -1,36 +1,36 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("EmoTracker.Data")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("EmoTracker.Data")] -[assembly: AssemblyCopyright("Copyright © 2019")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("8b95a4e0-8f5b-4894-b1a4-945e20c989e8")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("EmoTracker.Data")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("EmoTracker.Data")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("8b95a4e0-8f5b-4894-b1a4-945e20c989e8")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("3.0.1.11")] +[assembly: AssemblyVersion("3.0.1.11")] +[assembly: AssemblyFileVersion("3.0.1.11")] diff --git a/EmoTracker.Data/ScriptManager.cs b/EmoTracker.Data/ScriptManager.cs index 95f12af..01412b2 100644 --- a/EmoTracker.Data/ScriptManager.cs +++ b/EmoTracker.Data/ScriptManager.cs @@ -4,6 +4,7 @@ using EmoTracker.Data.Scripting; using Newtonsoft.Json; using NLua; +using NLua.Exceptions; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -179,6 +180,15 @@ function print(...) _output(printResult) end end + +-- Safe-call wrapper: invokes a function via xpcall so that debug.traceback +-- captures the Lua call stack at the point of failure. Returns: +-- true, result1, result2, ... on success +-- false, errorMessageWithTraceback on failure +function _safe_call(fn, ...) + local args = table.pack(...) + return xpcall(function() return fn(table.unpack(args, 1, args.n)) end, debug.traceback) +end "; IGamePackage mPackage; @@ -255,7 +265,7 @@ private object[] LoadScript(IGamePackage package, string path) byte[] buffer = new byte[s.Length]; if (s.Read(buffer, 0, buffer.Length) == buffer.Length) { - result = mLua.DoString(buffer); + result = mLua.DoString(buffer, path); } } else @@ -410,6 +420,7 @@ public void Output(string format, params object[] args) public void OutputException(Exception e) { JsonReaderException jsonException = e as JsonReaderException; + LuaException luaException = e as LuaException; if (jsonException != null) { ScriptManager.Instance.OutputError("JSON Parse Error"); @@ -421,6 +432,14 @@ public void OutputException(Exception e) OutputError(" For more information, see: {0}", jsonException.HelpLink); } } + else if (luaException != null) + { + ScriptManager.Instance.OutputError("Lua Execution Error"); + using (new LoggingBlock()) + { + ScriptManager.Instance.OutputError(luaException.Message); + } + } else { OutputError("Exception: {0}\n{1}", e.Message, e.StackTrace); @@ -483,6 +502,71 @@ public void Reset() ClearLogOutput(); } + /// + /// Invokes a LuaFunction via xpcall with debug.traceback as the error handler. + /// On success, returns the function's results. On failure, throws a LuaException + /// whose message includes the full Lua call stack at the point of failure. + /// + [NLua.LuaHide] + public object[] SafeCall(LuaFunction func, params object[] args) + { + if (mLua == null) + throw new InvalidOperationException("No Lua environment is loaded"); + + using (LuaFunction safeCall = mLua["_safe_call"] as LuaFunction) + { + if (safeCall == null) + return func.Call(args); // Fallback if _safe_call not available + + // Build argument array: _safe_call(func, arg1, arg2, ...) + object[] callArgs = new object[args.Length + 1]; + callArgs[0] = func; + Array.Copy(args, 0, callArgs, 1, args.Length); + + object[] result = safeCall.Call(callArgs); + + if (result == null || result.Length == 0) + return null; + + bool ok = Convert.ToBoolean(result[0]); + if (!ok) + { + string errorMsg = result.Length > 1 ? result[1]?.ToString() : "Unknown Lua error"; + throw new LuaException(errorMsg); + } + + // Strip the leading 'true' status from the results + if (result.Length <= 1) + return null; + + object[] actualResults = new object[result.Length - 1]; + Array.Copy(result, 1, actualResults, 0, actualResults.Length); + return actualResults; + } + } + + [NLua.LuaHide] + public object[] ExecuteLuaString(string luaCode) + { + if (mLua == null) + throw new InvalidOperationException("No Lua environment is loaded"); + return mLua.DoString(luaCode); + } + + [NLua.LuaHide] + public object GetLuaGlobal(string name) + { + if (mLua == null) + throw new InvalidOperationException("No Lua environment is loaded"); + return mLua[name]; + } + + [NLua.LuaHide] + public bool IsLuaLoaded + { + get { return mLua != null; } + } + [NLua.LuaHide] public object FindObjectForCode(string code) { @@ -528,11 +612,11 @@ public uint ProviderCountForCode(string code, out AccessibilityLevel maxAccessib IEnumerable args = tokens.Skip(1); if (args != null && args.Any()) { - result = func.Call(args.ToArray()); + result = SafeCall(func, args.ToArray()); } else { - result = func.Call(); + result = SafeCall(func); } if (result == null) @@ -633,7 +717,7 @@ public void InvokeStandardCallback(StandardCallback callback) using (LuaFunction func = mLua[functionName] as LuaFunction) { if (func != null) - func.Call(); + SafeCall(func); } } } @@ -659,11 +743,9 @@ public IMemorySegment AddMemoryWatch(string name, ulong startAddress, ulong leng { using (new LocationDatabase.SuspendRefreshScope()) { - LocationDatabase.Instance.SuspendRefresh = true; - if (callback != null) { - object[] results = callback.Call(segment); + object[] results = SafeCall(callback, segment); if (results != null && results.Length > 0) return Convert.ToBoolean(results.First()); diff --git a/EmoTracker.Data/Scripting/LuaItem.cs b/EmoTracker.Data/Scripting/LuaItem.cs index c112ea7..009c775 100644 --- a/EmoTracker.Data/Scripting/LuaItem.cs +++ b/EmoTracker.Data/Scripting/LuaItem.cs @@ -150,7 +150,7 @@ public override void AdvanceToCode(string code = null) try { if (AdvanceToCodeFunc != null) - AdvanceToCodeFunc.Call(this, code); + ScriptManager.Instance.SafeCall(AdvanceToCodeFunc, this, code); } catch (Exception e) { @@ -164,7 +164,7 @@ public override bool CanProvideCode(string code) { if (CanProvideCodeFunc != null) { - object[] result = CanProvideCodeFunc.Call(this, code); + object[] result = ScriptManager.Instance.SafeCall(CanProvideCodeFunc, this, code); if (result != null && result.Length > 0) return Convert.ToBoolean(result.First()); } @@ -182,7 +182,12 @@ public override void OnLeftClick() try { if (OnLeftClickFunc != null) - OnLeftClickFunc.Call(this); + { + using (new LocationDatabase.SuspendRefreshScope()) + { + ScriptManager.Instance.SafeCall(OnLeftClickFunc, this); + } + } } catch (Exception e) { @@ -195,7 +200,12 @@ public override void OnRightClick() try { if (OnRightClickFunc != null) - OnRightClickFunc.Call(this); + { + using (new LocationDatabase.SuspendRefreshScope()) + { + ScriptManager.Instance.SafeCall(OnRightClickFunc, this); + } + } } catch (Exception e) { @@ -209,7 +219,7 @@ public override uint ProvidesCode(string code) { if (ProvidesCodeFunc != null) { - object[] result = ProvidesCodeFunc.Call(this, code); + object[] result = ScriptManager.Instance.SafeCall(ProvidesCodeFunc, this, code); if (result != null && result.Length > 0) return Convert.ToUInt32(result.First()); } @@ -232,7 +242,7 @@ protected override bool Save(JObject data) { if (SaveFunc != null) { - object[] results = SaveFunc.Call(this); + object[] results = ScriptManager.Instance.SafeCall(SaveFunc, this); if (results != null && results.Length > 0) { LuaTable saveData = results.First() as LuaTable; @@ -294,7 +304,7 @@ protected override bool Load(JObject data) } } - object[] results = LoadFunc.Call(this, dataMap); + object[] results = ScriptManager.Instance.SafeCall(LoadFunc, this, dataMap); if (results != null && results.Length > 0) return Convert.ToBoolean(results.First()); @@ -316,7 +326,7 @@ public bool Set(string key, object value) try { if (PropertyChangedFunc != null) - PropertyChangedFunc.Call(this, key, v); + ScriptManager.Instance.SafeCall(PropertyChangedFunc, this, key, v); } catch (Exception e) { diff --git a/EmoTracker.Data/Tracker.cs b/EmoTracker.Data/Tracker.cs index 4ce3d0b..fee1dff 100644 --- a/EmoTracker.Data/Tracker.cs +++ b/EmoTracker.Data/Tracker.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Layout; using EmoTracker.Data.Locations; +using EmoTracker.Data.Media; using EmoTracker.Data.Packages; using EmoTracker.Data.Settings; using Newtonsoft.Json; @@ -498,6 +499,8 @@ public void Reload() mbReloadInProgress = false; + ItemDatabase.Instance.BuildCodeIndex(); + if (OnPackageLoadComplete != null) OnPackageLoadComplete(this, EventArgs.Empty); diff --git a/EmoTracker.UI/Controls/InputMaskingImage.cs b/EmoTracker.UI/Controls/InputMaskingImage.cs index 707c24c..f281414 100644 --- a/EmoTracker.UI/Controls/InputMaskingImage.cs +++ b/EmoTracker.UI/Controls/InputMaskingImage.cs @@ -1,38 +1,44 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; - -namespace EmoTracker.UI.Controls -{ - public class InputMaskingImage : Image - { - protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters) - { - try - { - var source = (BitmapSource)Source; - - // Get the pixel of the source that was hit - var x = Math.Min((int)(hitTestParameters.HitPoint.X / ActualWidth * source.PixelWidth), source.PixelWidth - 1); - var y = Math.Min((int)(hitTestParameters.HitPoint.Y / ActualHeight * source.PixelHeight), source.PixelHeight - 1); - - // Copy the single pixel into a new byte array representing RGBA - var pixel = new byte[4]; - source.CopyPixels(new Int32Rect(x, y, 1, 1), pixel, 4, 0); - - // Check the alpha (transparency) of the pixel - // - threshold can be adjusted from 0 to 255 - if (pixel[3] < 10) - return null; - - return new PointHitTestResult(this, hitTestParameters.HitPoint); - } - catch - { - return null; - } - } - } -} +using Avalonia.Controls; +using Avalonia.Rendering; +using EmoTracker.UI.Media.Utility; + +namespace EmoTracker.UI.Controls +{ + // Avalonia version: per-pixel hit testing via ICustomHitTest so transparent pixels + // are excluded from Avalonia's visual hit-test walk. This prevents transparent + // areas from blocking pointer events on elements below in z-order. + public class InputMaskingImage : Image, ICustomHitTest + { + public bool HitTest(Avalonia.Point point) + { + return HitTestAlphaMask(point); + } + + private bool HitTestAlphaMask(Avalonia.Point point) + { + try + { + if (Source == null) return false; + + var maskEntry = IconUtility.GetAlphaMask(Source); + // No mask → treat as fully transparent (click-through). + // This is critical for the async image pipeline: placeholder images + // don't have alpha masks, and returning true here would make them + // block ALL input until the real image loads. + if (maskEntry == null) return false; + + var (mask, maskW, maskH) = maskEntry.Value; + + int px = System.Math.Min((int)(point.X / Bounds.Width * maskW), maskW - 1); + int py = System.Math.Min((int)(point.Y / Bounds.Height * maskH), maskH - 1); + + if (px < 0 || py < 0) return false; + return mask[py * maskW + px]; + } + catch + { + return false; + } + } + } +} diff --git a/EmoTracker.UI/Controls/MarkdownViewer.cs b/EmoTracker.UI/Controls/MarkdownViewer.cs new file mode 100644 index 0000000..c40b5ea --- /dev/null +++ b/EmoTracker.UI/Controls/MarkdownViewer.cs @@ -0,0 +1,69 @@ +// Avalonia code-only implementation of MarkdownViewer (net8.0 target). +// Uses Markdig to convert markdown → HTML, then renders via HtmlLabel +// (Avalonia.HtmlRenderer). Replaces the Markdown.Avalonia implementation +// which is incompatible with Avalonia 11.3.3. +// The WPF version lives in MarkdownViewer.xaml / MarkdownViewer.xaml.cs (net8.0-windows target). +using Avalonia; +using Avalonia.Controls; +using Markdig; +using TheArtOfDev.HtmlRenderer.Avalonia; + +namespace EmoTracker.UI.Controls +{ + public class MarkdownViewer : UserControl + { + public static readonly StyledProperty MarkdownProperty = + AvaloniaProperty.Register(nameof(Markdown)); + + private static readonly MarkdownPipeline Pipeline = + new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + + private readonly HtmlLabel _label; + + public MarkdownViewer() + { + _label = new HtmlLabel + { + Background = Avalonia.Media.Brushes.Transparent, + IsHitTestVisible = false, + AutoSizeHeightOnly = true, + }; + Content = _label; + } + + public string Markdown + { + get => GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == MarkdownProperty) + { + string md = change.NewValue as string ?? string.Empty; + _label.Text = string.IsNullOrWhiteSpace(md) + ? string.Empty + : WrapWithCss(Markdig.Markdown.ToHtml(md, Pipeline)); + } + } + + private static string WrapWithCss(string html) => + "" + html + ""; + } +} diff --git a/EmoTracker.UI/Controls/MarkdownViewer.xaml b/EmoTracker.UI/Controls/MarkdownViewer.xaml deleted file mode 100644 index 96839f0..0000000 --- a/EmoTracker.UI/Controls/MarkdownViewer.xaml +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/EmoTracker.UI/Controls/MarkdownViewer.xaml.cs b/EmoTracker.UI/Controls/MarkdownViewer.xaml.cs deleted file mode 100644 index 7e0e570..0000000 --- a/EmoTracker.UI/Controls/MarkdownViewer.xaml.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; - -namespace EmoTracker.UI.Controls -{ - /// - /// Interaction logic for MarkdownViewer.xaml - /// - public partial class MarkdownViewer : UserControl - { - public MarkdownViewer() - { - InitializeComponent(); - } - - public string Markdown - { - get { return (string)GetValue(MarkdownProperty); } - set { SetValue(MarkdownProperty, value); } - } - - // Using a DependencyProperty as the backing store for Markdown. This enables animation, styling, binding, etc... - public static readonly DependencyProperty MarkdownProperty = - DependencyProperty.Register("Markdown", typeof(string), typeof(MarkdownViewer), new PropertyMetadata(null)); - } -} diff --git a/EmoTracker.UI/Controls/MouseOnlyButton.cs b/EmoTracker.UI/Controls/MouseOnlyButton.cs index 05ce6c4..5ae988c 100644 --- a/EmoTracker.UI/Controls/MouseOnlyButton.cs +++ b/EmoTracker.UI/Controls/MouseOnlyButton.cs @@ -1,20 +1,18 @@ -using System; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; namespace EmoTracker.UI.Controls { public class MouseOnlyButton : Button { - protected override void OnInitialized(EventArgs e) + public MouseOnlyButton() { IsTabStop = false; Focusable = false; - base.OnInitialized(e); } - protected override void OnPreviewKeyDown(KeyEventArgs e) + protected override void OnKeyDown(KeyEventArgs e) { e.Handled = true; } @@ -22,14 +20,13 @@ protected override void OnPreviewKeyDown(KeyEventArgs e) public class MouseOnlyToggleButton : ToggleButton { - protected override void OnInitialized(EventArgs e) + public MouseOnlyToggleButton() { IsTabStop = false; Focusable = false; - base.OnInitialized(e); } - protected override void OnPreviewKeyDown(KeyEventArgs e) + protected override void OnKeyDown(KeyEventArgs e) { e.Handled = true; } diff --git a/EmoTracker.UI/Controls/ObservableUserControl.cs b/EmoTracker.UI/Controls/ObservableUserControl.cs index 764ffef..74160ea 100644 --- a/EmoTracker.UI/Controls/ObservableUserControl.cs +++ b/EmoTracker.UI/Controls/ObservableUserControl.cs @@ -1,12 +1,13 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Runtime.CompilerServices; -using System.Windows.Controls; + +using Avalonia.Controls; namespace EmoTracker.UI.Controls { - public class ObservableUserControl : UserControl, INotifyPropertyChanged + public class ObservableUserControl : UserControl { - public event PropertyChangedEventHandler PropertyChanged; + public new event PropertyChangedEventHandler PropertyChanged; protected void NotifyPropertyChanged([CallerMemberName] string propertyName = null) { if (PropertyChanged != null) @@ -20,7 +21,6 @@ protected bool SetProperty(ref T field, T value, [CallerMemberName] string pr NotifyPropertyChanged(propertyName); return true; } - return false; } } diff --git a/EmoTracker.UI/Converters/BrushConverters.cs b/EmoTracker.UI/Converters/BrushConverters.cs new file mode 100644 index 0000000..c05794a --- /dev/null +++ b/EmoTracker.UI/Converters/BrushConverters.cs @@ -0,0 +1,133 @@ +#nullable enable annotations +using EmoTracker.Core; +using EmoTracker.Data.Locations; +using EmoTracker.Data.Settings; +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace EmoTracker.UI.Converters +{ + /// + /// Converts a colour name or hex string (e.g. "#ff3030", "DarkOrange") to an . + /// Returns null on failure so that FallbackValue can kick in. + /// + public class StringToBrushConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string s && !string.IsNullOrWhiteSpace(s)) + { + try + { + return Brush.Parse(s); + } + catch { } + } + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Converts an to the matching colour + /// taken from . + /// + public class AccessibilityLevelToBrushConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + string colorStr = "#333333"; + if (value is AccessibilityLevel level) + { + var c = ApplicationColors.Instance; + colorStr = level switch + { + AccessibilityLevel.Normal => c.AccessibilityColor_Normal, + AccessibilityLevel.Cleared => c.AccessibilityColor_Cleared, + AccessibilityLevel.None => c.AccessibilityColor_None, + AccessibilityLevel.Partial => c.AccessibilityColor_Partial, + AccessibilityLevel.Inspect => c.AccessibilityColor_Inspect, + AccessibilityLevel.SequenceBreak => c.AccessibilityColor_SequenceBreak, + AccessibilityLevel.Glitch => c.AccessibilityColor_Glitch, + AccessibilityLevel.Unlockable => c.AccessibilityColor_Unlockable, + _ => "#333333" + }; + } + + try + { + return Brush.Parse(colorStr); + } + catch + { + return null; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Converts a bool to an Avalonia + /// when true, or null when false. + /// Mirrors the WPF DropShadowEffect (BlurRadius=15, ShadowDepth=0, Opacity=0.8) which produces + /// a centred glow with no directional offset. + /// + public class BoolToDropShadowEffectConverter : Singleton, IValueConverter + { + private static readonly DropShadowDirectionEffect s_effect = + new DropShadowDirectionEffect + { + BlurRadius = 15, + ShadowDepth = 0, + Opacity = 0.8, + Color = Colors.Black, + }; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? (object)s_effect : null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Multi-value converter for the Package Manager button foreground color. + /// Replicates WPF DataTrigger priority: !AnyPackagesInstalled → Active, + /// CurrentPackageHasUpdateAvailable → Warning, UpdatesAvailable → Active, else default gray. + /// values[0] = UpdatesAvailable (bool), values[1] = CurrentPackageHasUpdateAvailable (bool), + /// values[2] = AnyPackagesInstalled (bool). + /// + public class PackageManagerForegroundConverter : Singleton, IMultiValueConverter + { + private static readonly IBrush DefaultBrush = new SolidColorBrush(Color.Parse("#717171")); + + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + bool updatesAvailable = values.Count > 0 && values[0] is true; + bool currentHasUpdate = values.Count > 1 && values[1] is true; + bool anyInstalled = values.Count > 2 && values[2] is true; + + // WPF trigger priority: last matching trigger wins. + // Order: UpdatesAvailable, CurrentPackageHasUpdateAvailable, !AnyPackagesInstalled + if (!anyInstalled) + return new SolidColorBrush(Color.Parse( + ApplicationColors.Instance.Status_Generic_Active)); + if (currentHasUpdate) + return new SolidColorBrush(Color.Parse( + ApplicationColors.Instance.Status_Generic_Warning)); + if (updatesAvailable) + return new SolidColorBrush(Color.Parse( + ApplicationColors.Instance.Status_Generic_Active)); + + return DefaultBrush; + } + } +} diff --git a/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs b/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs index e97278f..e8be399 100644 --- a/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs +++ b/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; +using System; using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Data; + +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters { @@ -23,9 +20,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn return true; } } - catch - { - } + catch { } return false; } diff --git a/EmoTracker.UI/Converters/GamePackageConverters.cs b/EmoTracker.UI/Converters/GamePackageConverters.cs index dc8aece..be53f88 100644 --- a/EmoTracker.UI/Converters/GamePackageConverters.cs +++ b/EmoTracker.UI/Converters/GamePackageConverters.cs @@ -1,8 +1,9 @@ -using EmoTracker.Core; +using EmoTracker.Core; using EmoTracker.Data.Packages; using System; using System.Globalization; -using System.Windows.Data; + +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters { @@ -37,7 +38,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn var game = PackageManager.Instance.FindGame(name); if (game != null) - return Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + return Media.ImageReferenceService.Instance.RequestImage(game.Image); return null; } diff --git a/EmoTracker.UI/Converters/ImageReferenceConverter.cs b/EmoTracker.UI/Converters/ImageReferenceConverter.cs index 4e5f3d5..d49eaa5 100644 --- a/EmoTracker.UI/Converters/ImageReferenceConverter.cs +++ b/EmoTracker.UI/Converters/ImageReferenceConverter.cs @@ -1,26 +1,33 @@ -using EmoTracker.Core; -using EmoTracker.Data.Media; -using EmoTracker.UI.Media; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class ImageReferenceConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return ImageReferenceService.Instance.ResolveImageReference(value as ImageReference); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Media; +using EmoTracker.UI.Media; +using System; +using System.Globalization; + +using Avalonia.Data.Converters; + +namespace EmoTracker.UI.Converters +{ + public class ImageReferenceConverter : Singleton, IValueConverter + { + /// + /// Returns the cached image for the given , + /// or the placeholder if the image is still being resolved in the + /// background. Boosts the reference to immediate priority so it is + /// resolved as soon as possible. + /// + /// Note: This converter is used by bindings that have not yet been + /// migrated to the path-through pattern (e.g. {Binding Icon.ResolvedImage}). + /// Bindings using the path-through pattern do not need a converter. + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return ImageReferenceService.Instance.RequestImage(value as ImageReference); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Converters/InverseTransformConverter.cs b/EmoTracker.UI/Converters/InverseTransformConverter.cs index 4a33854..8c75daf 100644 --- a/EmoTracker.UI/Converters/InverseTransformConverter.cs +++ b/EmoTracker.UI/Converters/InverseTransformConverter.cs @@ -1,27 +1,28 @@ -using EmoTracker.Core; +using EmoTracker.Core; using System; using System.Globalization; -using System.Windows; -using System.Windows.Data; -using System.Windows.Media; + +using Avalonia.Data.Converters; +using Avalonia.Media; namespace EmoTracker.UI.Converters { public class InverseTransformConverter : Singleton, IValueConverter { - public object Convert(object value, Type targetType, - object parameter, CultureInfo culture) + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - Transform transform = value as Transform; - if (transform == null) - return Transform.Identity; - return transform.Inverse; + if (value is ITransform avTransform) + { + var matrix = avTransform.Value; + if (matrix.TryInvert(out var inverse)) + return new MatrixTransform(inverse); + } + return null; } - public object ConvertBack(object value, Type targetType, - object parameter, CultureInfo culture) + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - return DependencyProperty.UnsetValue; + throw new NotImplementedException(); } } } diff --git a/EmoTracker.UI/Converters/LayoutConverters.cs b/EmoTracker.UI/Converters/LayoutConverters.cs new file mode 100644 index 0000000..ab1e582 --- /dev/null +++ b/EmoTracker.UI/Converters/LayoutConverters.cs @@ -0,0 +1,79 @@ +#nullable enable annotations +using EmoTracker.Core; +using EmoTracker.Data.Layout; +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Data.Converters; + +namespace EmoTracker.UI.Converters +{ + /// + /// Converts a dock location string ("left","right","top","bottom") to a enum value. + /// Returns for null/empty/unrecognised strings. + /// + public class StringToDockConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string s && !string.IsNullOrEmpty(s) && + Enum.TryParse(s, ignoreCase: true, out var result)) + return result; + return Dock.Left; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Maps EmoTracker.Data.Layout.Orientation.Horizontal → + /// and Vertical → . + /// When locations wrap horizontally their height should stay uniform; when they wrap + /// vertically their width should stay uniform. + /// + public class OrientationToPreserveDimensionConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is Orientation orientation) + { + return orientation == Orientation.Horizontal + ? PreserveDimension.Height + : PreserveDimension.Width; + } + return PreserveDimension.None; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Multi-value converter that selects the correct + /// based on and . + /// values[0] = PanelStyle, values[1] = EmoTracker.Data.Layout.Orientation. + /// Returns a StackPanel template for Stack, WrapPanel template for Wrap. + /// + public class PanelStyleToTemplateConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + var style = values.Count > 0 && values[0] is PanelStyle ps ? ps : PanelStyle.Stack; + var orientation = Avalonia.Layout.Orientation.Vertical; + if (values.Count > 1 && values[1] is Orientation dataOrientation + && dataOrientation == Orientation.Horizontal) + { + orientation = Avalonia.Layout.Orientation.Horizontal; + } + + if (style == PanelStyle.Wrap) + return new FuncTemplate(() => new WrapPanel { Orientation = orientation }); + else + return new FuncTemplate(() => new StackPanel { Orientation = orientation }); + } + } +} diff --git a/EmoTracker.UI/Converters/LayoutReferenceConverter.cs b/EmoTracker.UI/Converters/LayoutReferenceConverter.cs index 013bffa..1e07b8e 100644 --- a/EmoTracker.UI/Converters/LayoutReferenceConverter.cs +++ b/EmoTracker.UI/Converters/LayoutReferenceConverter.cs @@ -1,8 +1,9 @@ -using EmoTracker.Core; +using EmoTracker.Core; using EmoTracker.Data.Layout; using System; using System.Globalization; -using System.Windows.Data; + +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters { @@ -17,9 +18,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn string layoutName = value.ToString(); return LayoutManager.Instance.FindLayout(layoutName); } - catch - { - } + catch { } } if (parameter != null) @@ -29,9 +28,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn string layoutName = parameter.ToString(); return LayoutManager.Instance.FindLayout(layoutName); } - catch - { - } + catch { } } return null; diff --git a/EmoTracker.UI/Converters/LocationConverters.cs b/EmoTracker.UI/Converters/LocationConverters.cs new file mode 100644 index 0000000..a9d2add --- /dev/null +++ b/EmoTracker.UI/Converters/LocationConverters.cs @@ -0,0 +1,78 @@ +#nullable enable annotations +using EmoTracker.Core; +using EmoTracker.Data.Locations; +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia.Data.Converters; + +namespace EmoTracker.UI.Converters +{ + /// + /// Multi-value converter that replicates WPF's MultiDataTrigger-based map location + /// visibility logic. Evaluates (in priority order): ForceInvisible, ForceVisible, + /// HasVisibleSections, Cleared+DisplayAll+Shift, Empty+DisplayAll+Shift. + /// Binding order: + /// [0] ForceVisible (bool), [1] ForceInvisible (bool), + /// [2] Location.HasVisibleSections (bool), [3] Location.AccessibilityLevel (enum), + /// [4] Location.HasAvailableItems (bool), [5] Location.Badges.Count (int), + /// [6] Location.NoteTakingSite.Empty (bool), + /// [7] DisplayAllLocations (bool), [8] IsShiftPressed (bool). + /// + public class MapLocationVisibilityConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Count < 9) return true; + + bool forceVisible = values[0] is true; + bool forceInvisible = values[1] is true; + bool hasVisibleSections = values[2] is true; + bool isCleared = values[3] is AccessibilityLevel level + && level == AccessibilityLevel.Cleared; + bool hasAvailableItems = values[4] is true; + int badgeCount = values[5] is int bc ? bc : 0; + bool notesEmpty = values[6] is not false; // default true when null/unset + bool displayAll = values[7] is true; + bool shiftPressed = values[8] is true; + + // Highest priority: script-driven force rules (last WPF triggers win) + if (forceInvisible) return false; + if (forceVisible) return true; + + // No visible sections at all → hide + if (!hasVisibleSections) return false; + + // Cleared location hidden unless DisplayAll or Shift + if (isCleared && !displayAll && !shiftPressed) return false; + + // Empty location (no items, badges, or notes) hidden unless DisplayAll or Shift + if (!hasAvailableItems && badgeCount == 0 && notesEmpty && !displayAll && !shiftPressed) + return false; + + return true; + } + } + + /// + /// Replicates the WPF MultiDataTrigger that controlled chest accessibility. + /// Returns false (inaccessible / grayed-out / disabled) when all three conditions + /// are true: AccessibilityLevel == None, AlwaysAllowChestManipulation == false, and + /// AlwaysAllowClearing == false. Returns true in all other cases. + /// Binding order: + /// [0] AccessibilityLevel, [1] AlwaysAllowChestManipulation (bool), + /// [2] ApplicationSettings.AlwaysAllowClearing (bool). + /// + public class ChestAccessibleConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Count < 3) return true; + bool isNone = values[0] is AccessibilityLevel level && level == AccessibilityLevel.None; + bool alwaysAllowSection = values[1] is true; + bool alwaysAllowGlobal = values[2] is true; + return !(isNone && !alwaysAllowSection && !alwaysAllowGlobal); + } + } +} diff --git a/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs b/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs index 689ec59..1038a9c 100644 --- a/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs +++ b/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs @@ -1,27 +1,9 @@ -using EmoTracker.Core; +using EmoTracker.Core; using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Data; + +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters.Markdown { - public class MarkdownToFlowDocumentConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value != null) - return MarkdownProcessor.AsFlowDocument(value.ToString()); - - return MarkdownProcessor.AsFlowDocument(null); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } } diff --git a/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs b/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs index 7a1086b..86469c5 100644 --- a/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs +++ b/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs @@ -1,25 +1,11 @@ -using Markdig; -using Markdig.Wpf; +using Markdig; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Documents; -using System.Windows.Media; -using System.Xml; + namespace EmoTracker.UI.Converters.Markdown { public class MarkdownProcessor { - public static FlowDocument AsFlowDocument(string markdown) - { - FlowDocument doc = Markdig.Wpf.Markdown.ToFlowDocument(markdown ?? "", new MarkdownPipelineBuilder().UseSupportedExtensions().Build()); - doc.FontSize = 10; - return doc; - } public static string AsHtml(string markdown) { diff --git a/EmoTracker.UI/Converters/NumericConverters.cs b/EmoTracker.UI/Converters/NumericConverters.cs new file mode 100644 index 0000000..6f0b513 --- /dev/null +++ b/EmoTracker.UI/Converters/NumericConverters.cs @@ -0,0 +1,161 @@ +#nullable enable annotations +using EmoTracker.Core; +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media.Imaging; + +namespace EmoTracker.UI.Converters +{ + /// + /// Returns when the value is negative; otherwise returns the value as-is. + /// Use for Width/Height bindings where -1 means "unset / auto". + /// + public class NegativeToNaNDoubleConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d < 0 ? double.NaN : d; + return double.NaN; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns 0.0 when the value is negative; otherwise returns the value as-is. + /// Use for MinWidth/MinHeight bindings where -1 means "no minimum" (platform default is 0). + /// + public class NegativeToZeroDoubleConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d < 0 ? 0.0 : d; + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns when the value is negative; otherwise returns the value as-is. + /// Use for MaxWidth/MaxHeight bindings where -1 means "no maximum" (platform default is ∞). + /// + public class NegativeToInfinityDoubleConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d < 0 ? double.PositiveInfinity : d; + return double.PositiveInfinity; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns when the value is negative, + /// causing the binding to fall back to the target property's default value. + /// If a ConverterParameter is supplied, returns that as a double instead + /// of UnsetValue, allowing callers to specify a fallback size explicitly. + /// Use for IconWidth/IconHeight bindings where -1 means "use a sensible default" + /// rather than NaN (auto-size to source dimensions — can be huge for banner images). + /// + public class NegativeToUnsetConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d && d >= 0) return d; + if (parameter != null + && double.TryParse(parameter.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double fallback)) + return fallback; + return AvaloniaProperty.UnsetValue; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns 0.0 when the value is negative or zero; otherwise returns the value as-is. + /// Use for Canvas.Left / Canvas.Top bindings where -1 means "default / unset". + /// + public class CanvasPositionConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d <= 0 ? 0.0 : d; + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns 0 when the value is negative or zero; otherwise returns (int)value. + /// Use for Canvas.ZIndex bindings where -1 means "default". + /// + public class CanvasZIndexConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d <= 0 ? 0 : (int)d; + return 0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Multi-value converter for icon dimensions. Supports three behaviours: + /// + /// Both dimensions specified → honor both (image may be distorted). + /// Only this dimension specified → use it directly. + /// Only the other dimension specified → compute this one proportionally from + /// the source image's aspect ratio. + /// Neither specified → fall back to the source image's natural pixel size. + /// + /// Use ConverterParameter="Width" or "Height" to select which dimension + /// is being resolved. + /// values[0] = this dimension (IconWidth or IconHeight), + /// values[1] = other dimension (IconHeight or IconWidth), + /// values[2] = Image.Source (IImage, for aspect-ratio and pixel-size fallback). + /// + public class IconDimensionMultiConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + double myDim = values.Count > 0 && values[0] is double d0 ? d0 : double.NaN; + double otherDim = values.Count > 1 && values[1] is double d1 ? d1 : double.NaN; + bool isWidth = string.Equals(parameter?.ToString(), "Width", StringComparison.OrdinalIgnoreCase); + Bitmap bitmap = values.Count > 2 ? values[2] as Bitmap : null; + + // If this dimension is explicitly specified (including zero), use it directly. + if (!double.IsNaN(myDim) && myDim >= 0) + return myDim; + + // Only the other dimension was specified: scale proportionally from the image aspect ratio. + if (otherDim > 0 && !double.IsNaN(otherDim) && bitmap != null) + { + double pixW = bitmap.PixelSize.Width; + double pixH = bitmap.PixelSize.Height; + if (pixW > 0 && pixH > 0) + return isWidth ? otherDim * pixW / pixH : otherDim * pixH / pixW; + } + + // Neither dimension specified: fall back to the image's natural pixel size. + if (bitmap != null) + return isWidth ? (double)bitmap.PixelSize.Width : (double)bitmap.PixelSize.Height; + + // Last resort default. + return 32.0; + } + } +} diff --git a/EmoTracker.UI/Converters/StringConverters.cs b/EmoTracker.UI/Converters/StringConverters.cs index 60636d5..918fe48 100644 --- a/EmoTracker.UI/Converters/StringConverters.cs +++ b/EmoTracker.UI/Converters/StringConverters.cs @@ -1,11 +1,32 @@ -using EmoTracker.Core; +using EmoTracker.Core; using System; +using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; -using System.Windows.Data; + +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters { + /// + /// Multi-value converter for the Package Manager button tooltip. + /// values[0] = UpdatesAvailable (bool), values[1] = CurrentPackageHasUpdateAvailable (bool), + /// values[2] = AnyPackagesInstalled (bool). + /// + public class PackageManagerTooltipConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + bool updatesAvailable = values.Count > 0 && values[0] is true; + bool currentHasUpdate = values.Count > 1 && values[1] is true; + bool anyInstalled = values.Count > 2 && values[2] is true; + + if (!anyInstalled) return "Install your first package!"; + if (currentHasUpdate || updatesAvailable) return "Package Updates Are Available"; + return "Package Manager"; + } + } + public class EnspacenCamelCaseConverter : Singleton, IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) @@ -14,9 +35,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn { return Regex.Replace(value.ToString(), "([a-z](?=[A-Z0-9])|[A-Z](?=[A-Z][a-z]))", "$1 "); } - catch - { - } + catch { } return null; } diff --git a/EmoTracker.UI/Converters/ThicknessConverter.cs b/EmoTracker.UI/Converters/ThicknessConverter.cs index 97a149d..14666d9 100644 --- a/EmoTracker.UI/Converters/ThicknessConverter.cs +++ b/EmoTracker.UI/Converters/ThicknessConverter.cs @@ -1,10 +1,16 @@ -using EmoTracker.Core; +using EmoTracker.Core; using System; using System.Globalization; -using System.Windows.Data; + +using Avalonia; +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters { + /// + /// Converts a model value to the platform Thickness type. + /// Used by LocationMapControl and similar controls that bind a structured Thickness model. + /// public class ThicknessConverter : Singleton, IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) @@ -12,11 +18,9 @@ public object Convert(object value, Type targetType, object parameter, CultureIn try { Data.Media.Thickness thickness = (Data.Media.Thickness)value; - return new System.Windows.Thickness(thickness.Left, thickness.Top, thickness.Right, thickness.Bottom); - } - catch - { + return new Thickness(thickness.Left, thickness.Top, thickness.Right, thickness.Bottom); } + catch { } return null; } @@ -26,4 +30,45 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu throw new NotImplementedException(); } } + + /// + /// Converts a to a uniform . + /// Use for BorderThickness bindings where the data model stores a single double. + /// + public class DoubleToThicknessConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + double d = value is double dv ? dv : 0.0; + return new Thickness(d); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Converts a margin string (e.g. "5", "5,10", "5,10,5,10") to an Avalonia + /// . + /// + /// Avalonia's TypeConverter is only applied during XAML literal parsing, not at binding + /// resolution time, so a {Binding Margin} where the source is a string + /// silently falls back to Thickness(0) without this explicit converter. + /// + /// + public class StringToThicknessConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string s && !string.IsNullOrWhiteSpace(s)) + { + try { return Thickness.Parse(s); } + catch { } + } + return new Thickness(0); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } } diff --git a/EmoTracker.UI/Converters/TrivialEnumConverter.cs b/EmoTracker.UI/Converters/TrivialEnumConverter.cs index fa0d73e..41cf8dc 100644 --- a/EmoTracker.UI/Converters/TrivialEnumConverter.cs +++ b/EmoTracker.UI/Converters/TrivialEnumConverter.cs @@ -1,7 +1,8 @@ -using EmoTracker.Core; +using EmoTracker.Core; using System; using System.Globalization; -using System.Windows.Data; + +using Avalonia.Data.Converters; namespace EmoTracker.UI.Converters { @@ -13,9 +14,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn { return Enum.Parse(targetType, value.ToString()); } - catch - { - } + catch { } return null; } diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs new file mode 100644 index 0000000..21ddd9c --- /dev/null +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -0,0 +1,110 @@ +#nullable enable annotations +using EmoTracker.Core; +using System; +using System.Globalization; + +using Avalonia; +using Avalonia.Data; +using Avalonia.Data.Converters; + +namespace EmoTracker.UI.Converters +{ + /// + /// Returns true when the value is null or unset, false when non-null. + /// Use for IsVisible bindings where the element should appear when no data is present. + /// + public class NullToTrueConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value == null || value == AvaloniaProperty.UnsetValue || value == BindingOperations.DoNothing; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns true when the value is non-null and non-unset, false otherwise. + /// + public class NullToFalseConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value != null && value != AvaloniaProperty.UnsetValue && value != BindingOperations.DoNothing; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns true when the value is a non-null, non-empty string. + /// Treats UnsetValue and DoNothing as empty on Avalonia. + /// + public class NonEmptyStringToBoolConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is string s && s.Length > 0; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns true when the integer/uint value is non-zero. + /// + public class NonZeroToBoolConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is int i) return i != 0; + if (value is uint u) return u != 0; + if (value is long l) return l != 0; + if (value is double d) return d != 0; + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Inverts a value. + /// + public class BoolInverseConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + } + + /// + /// Alias for — inverts a value. + /// + public class InverseBoolConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + } + + /// + /// Returns true when the value's ToString() matches the ConverterParameter + /// string (case-insensitive). Useful for controlling IsVisible based on an enum property. + /// IsVisible="{Binding Style, Converter={x:Static converters:ObjectEqualsConverter.Instance}, ConverterParameter=Settings}" + /// + public class ObjectEqualsConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null || parameter == null) + return false; + return string.Equals(value.ToString(), parameter.ToString(), StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + +} diff --git a/EmoTracker.UI/EmoTracker.UI.csproj b/EmoTracker.UI/EmoTracker.UI.csproj index b2b98f1..5a2a4fa 100644 --- a/EmoTracker.UI/EmoTracker.UI.csproj +++ b/EmoTracker.UI/EmoTracker.UI.csproj @@ -5,9 +5,9 @@ Library EmoTracker.UI EmoTracker.UI - net472 - true + net8.0 false + true @@ -15,8 +15,20 @@ + - + + + + + + + + + + + + diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index 7e79219..5b1aa8a 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -1,51 +1,514 @@ -using EmoTracker.Core; -using EmoTracker.Data; -using EmoTracker.Data.Media; -using EmoTracker.UI.Media.Resolvers; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; - -namespace EmoTracker.UI.Media -{ - public class ImageReferenceService : ObservableSingleton - { - Dictionary mCache = new Dictionary(); - - public void ClearImageCache() - { - mCache.Clear(); - } - - public ImageSource ResolveImageReference(ImageReference imageRef) - { - if (imageRef == null) - return null; - - ImageSource cachedSrc; - if (mCache.TryGetValue(imageRef, out cachedSrc)) - return cachedSrc; - - foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) - { - if (entry.CanResolveReference(imageRef)) - { - ImageSource src = entry.ResolveReference(imageRef); - try - { - if (src != null && src.CanFreeze) - src.Freeze(); - } - catch { } - mCache[imageRef] = src; - return src; - } - } - - return null; - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Media; +using EmoTracker.UI.Media.Resolvers; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace EmoTracker.UI.Media +{ + /// + /// Priority levels for image resolution work items. + /// Lower numeric value = higher priority. + /// + public enum ImagePriority + { + /// The UI is waiting to display this image right now. + Immediate = 0, + /// Standard pre-cache priority during pack load. + Normal = 100, + } + + public class ImageReferenceService : ObservableSingleton + { + // ── Cache ─────────────────────────────────────────────────────── + readonly ConcurrentDictionary mCache = new ConcurrentDictionary(); + readonly object mResolutionLock = new object(); + + // ── Instance Tracking ─────────────────────────────────────────── + // Multiple distinct ImageReference objects can share the same equality + // key (e.g. 10 items that all use "items/small_key.png" each create + // their own ConcreteImageReference). Only one object per equality key + // enters the work queue, but ALL objects need their ResolvedImage set + // when resolution completes. This dictionary tracks every unresolved + // instance keyed by equality so PostResolvedImage can fan out to all. + readonly Dictionary> mPendingInstances = new Dictionary>(); + readonly object mInstancesLock = new object(); + + // ── Sized Placeholders ────────────────────────────────────────── + // Cache of transparent placeholder bitmaps keyed by (width, height). + // Shared across all references with the same source dimensions so + // we don't create thousands of identical bitmaps. + static readonly Dictionary<(int w, int h), IImage> sPlaceholderCache = new Dictionary<(int, int), IImage>(); + static readonly object sPlaceholderLock = new object(); + + /// + /// Returns a transparent placeholder image of the specified size. + /// Reuses cached instances for identical dimensions. + /// Falls back to 1×1 if width or height is zero. + /// + public static IImage GetPlaceholder(int width, int height) + { + if (width <= 0 || height <= 0) + width = height = 1; + + lock (sPlaceholderLock) + { + var key = (width, height); + if (sPlaceholderCache.TryGetValue(key, out IImage existing)) + return existing; + + var bmp = new WriteableBitmap( + new Avalonia.PixelSize(width, height), + new Avalonia.Vector(96, 96), + Avalonia.Platform.PixelFormat.Bgra8888, + AlphaFormat.Premul); + sPlaceholderCache[key] = bmp; + return bmp; + } + } + + /// + /// Returns a correctly-sized transparent placeholder for the given + /// image reference, using its + /// and to determine size. + /// + public static IImage GetPlaceholder(ImageReference imageRef) + { + if (imageRef == null) + return GetPlaceholder(1, 1); + return GetPlaceholder(imageRef.SourceWidth, imageRef.SourceHeight); + } + + // ── Priority Queue ────────────────────────────────────────────── + readonly object mQueueLock = new object(); + readonly SortedDictionary<(int Priority, long Order), ImageReference> mQueue = new SortedDictionary<(int, long), ImageReference>(); + readonly Dictionary mQueueIndex = new Dictionary(); + long mInsertionOrder; + readonly ManualResetEventSlim mQueueSignal = new ManualResetEventSlim(false); + + // ── Worker Thread ─────────────────────────────────────────────── + Thread mWorkerThread; + volatile bool mShutdown; + + /// + /// When true, resolves synchronously on the + /// calling thread (the pre-refactor behavior). The background worker + /// thread is not started. Set before calling . + /// + public bool SyncMode { get; set; } + + // ── Public API ────────────────────────────────────────────────── + + /// + /// Starts the background worker thread and wires up the + /// callback + /// so that newly-created references are automatically queued. + /// Call once at application startup. + /// + public void Start() + { + if (mWorkerThread != null) + return; + + mShutdown = false; + + if (SyncMode) + { + // In sync mode, resolve images immediately on creation so + // path-through bindings ({Binding Icon.ResolvedImage}) see + // the resolved image right away. + ImageReference.OnImageReferenceCreated = (imageRef) => + { + ResolveImageReference(imageRef); + }; + return; + } + + ImageReference.OnImageReferenceCreated = (imageRef) => + { + // Multiple ImageReference objects can share the same equality key + // (same URI + filter) while being distinct object instances (e.g. + // 10 items that use the same small-key icon each create their own + // ConcreteImageReference). If the image is already cached (first + // instance was resolved), set the result immediately. + IImage cached = GetCachedImage(imageRef); + if (cached != null) + { + imageRef.ResolvedImage = cached; + return; + } + + // Register this instance so that when resolution completes for + // any equal key, ALL instances get their ResolvedImage updated. + RegisterPendingInstance(imageRef); + + // Set a correctly-sized placeholder as ResolvedImage immediately + // so Avalonia's layout system measures controls at the right size + // before the real image is resolved on the background thread. + if (imageRef.ResolvedImage == null && + imageRef.SourceWidth > 0 && imageRef.SourceHeight > 0) + { + imageRef.ResolvedImage = GetPlaceholder(imageRef); + } + + QueueResolution(imageRef, ImagePriority.Normal); + }; + + mWorkerThread = new Thread(WorkerLoop) + { + Name = "ImageReferenceService Worker", + IsBackground = true, + Priority = ThreadPriority.BelowNormal + }; + mWorkerThread.Start(); + } + + /// + /// Signals the background worker to stop and waits for it to finish. + /// Call at application shutdown. + /// + public void Stop() + { + mShutdown = true; + mQueueSignal.Set(); + + ImageReference.OnImageReferenceCreated = null; + + // Don't block indefinitely – the thread is IsBackground so it + // will be torn down if the process exits. + mWorkerThread?.Join(2000); + mWorkerThread = null; + } + + /// + /// Clears all cached images and drains the work queue. + /// Called on pack unload so stale images are discarded. + /// + public void ClearImageCache() + { + lock (mQueueLock) + { + mQueue.Clear(); + mQueueIndex.Clear(); + mQueueSignal.Reset(); + } + mCache.Clear(); + + lock (mInstancesLock) + { + mPendingInstances.Clear(); + } + + // Clear the source image cache used by ConcreteImageReferenceResolver + ConcreteImageReferenceResolver.ClearSourceCache(); + } + + /// + /// Adds an to the background work queue + /// at the specified priority. If the reference is already queued at a + /// lower priority (higher numeric value), it is boosted. + /// Thread-safe; may be called from any thread. + /// + public void QueueResolution(ImageReference imageRef, ImagePriority priority) + { + if (imageRef == null) + return; + + // Already resolved – nothing to do. + if (mCache.ContainsKey(imageRef)) + return; + + int pri = (int)priority; + + lock (mQueueLock) + { + if (mQueueIndex.TryGetValue(imageRef, out var existing)) + { + if (pri >= existing.Priority) + return; // already at same or higher priority + + // Remove old entry and re-insert at higher priority + mQueue.Remove(existing); + mQueueIndex.Remove(imageRef); + } + + var key = (pri, Interlocked.Increment(ref mInsertionOrder)); + mQueue[key] = imageRef; + mQueueIndex[imageRef] = key; + mQueueSignal.Set(); + } + } + + /// + /// Returns the cached image for the given reference, or null + /// if it has not been resolved yet. + /// + public IImage GetCachedImage(ImageReference imageRef) + { + if (imageRef == null) + return null; + mCache.TryGetValue(imageRef, out IImage cached); + return cached; + } + + /// + /// Synchronously resolves an image reference. Used by composite + /// resolvers (Filter, Layered) that need resolved sub-images + /// during background resolution. Not intended for UI-thread callers – + /// those should read or call + /// instead. + /// + public IImage ResolveImageReference(ImageReference imageRef) + { + if (imageRef == null) + return null; + + // Fast path: lock-free cache read. + // In sync mode, also set ResolvedImage on the requesting object so + // duplicate ImageReference instances (same URI+filter, different object) + // get the resolved image immediately. + if (mCache.TryGetValue(imageRef, out IImage cachedSrc)) + { + if (SyncMode && imageRef.ResolvedImage as IImage != cachedSrc) + imageRef.ResolvedImage = cachedSrc; + return cachedSrc; + } + + // Slow path: acquire lock for resolution + IImage result; + lock (mResolutionLock) + { + // Double-check after acquiring lock + if (mCache.TryGetValue(imageRef, out cachedSrc)) + { + if (SyncMode && imageRef.ResolvedImage as IImage != cachedSrc) + imageRef.ResolvedImage = cachedSrc; + return cachedSrc; + } + + result = ResolveAndCache(imageRef); + } + + // In sync mode, set ResolvedImage directly so path-through + // bindings see the image immediately. This is safe because + // sync-mode callers are on the UI thread. + if (result != null && SyncMode) + imageRef.ResolvedImage = result; + + return result; + } + + /// + /// Called by UI-thread code (converters, bindings) when an image is + /// needed for display. Returns the cached image if available, otherwise + /// boosts the reference to and + /// returns a correctly-sized placeholder. + /// + public IImage RequestImage(ImageReference imageRef) + { + if (imageRef == null) + return null; + + if (mCache.TryGetValue(imageRef, out IImage cached)) + return cached; + + if (SyncMode) + return ResolveImageReference(imageRef); + + QueueResolution(imageRef, ImagePriority.Immediate); + return GetPlaceholder(imageRef); + } + + /// + /// Returns the number of items currently in the background work queue. + /// + public int QueueCount + { + get { lock (mQueueLock) { return mQueue.Count; } } + } + + /// + /// Returns the number of resolved images in the cache. + /// + public int CacheCount => mCache.Count; + + // ── Instance Tracking ─────────────────────────────────────────── + + /// + /// Registers an ImageReference object so that when an equal key is + /// resolved, this specific object's ResolvedImage gets set too. + /// + void RegisterPendingInstance(ImageReference imageRef) + { + lock (mInstancesLock) + { + if (!mPendingInstances.TryGetValue(imageRef, out var list)) + { + list = new List(); + mPendingInstances[imageRef] = list; + } + list.Add(imageRef); + } + } + + /// + /// Removes and returns all pending instances for the given equality key. + /// + List TakePendingInstances(ImageReference imageRef) + { + lock (mInstancesLock) + { + if (mPendingInstances.TryGetValue(imageRef, out var list)) + { + mPendingInstances.Remove(imageRef); + return list; + } + return null; + } + } + + // ── Internal Resolution ───────────────────────────────────────── + + IImage ResolveAndCache(ImageReference imageRef) + { + foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) + { + if (entry.CanResolveReference(imageRef)) + { + IImage src = entry.ResolveReference(imageRef); + if (src != null) + mCache[imageRef] = src; + return src; + } + } + + return null; + } + + // ── Background Worker ─────────────────────────────────────────── + + void WorkerLoop() + { + while (!mShutdown) + { + // Wait for work or shutdown signal + mQueueSignal.Wait(); + + if (mShutdown) + break; + + // Process items until the queue is drained + while (TryDequeueNext(out var imageRef)) + { + if (mShutdown) + break; + + // Skip if already resolved (may have been resolved by a + // recursive call from a Filter/Layered resolver). + if (mCache.ContainsKey(imageRef)) + { + PostResolvedImage(imageRef, mCache[imageRef]); + continue; + } + + try + { + IImage resolved; + lock (mResolutionLock) + { + // Double-check under lock + if (mCache.TryGetValue(imageRef, out resolved)) + { + PostResolvedImage(imageRef, resolved); + continue; + } + + resolved = ResolveAndCache(imageRef); + } + + PostResolvedImage(imageRef, resolved); + } + catch + { + // Individual resolution failures are silently ignored; + // the UI will continue showing the placeholder. + } + } + + // All Immediate-priority items have been resolved. Force + // the UI to re-measure layouts so controls that were sized + // for placeholders adopt the final image dimensions. + Dispatcher.UIThread.Post(() => + { + if (Avalonia.Application.Current?.ApplicationLifetime + is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow?.InvalidateMeasure(); + } + }, DispatcherPriority.Background); + } + } + + bool TryDequeueNext(out ImageReference imageRef) + { + lock (mQueueLock) + { + if (mQueue.Count == 0) + { + mQueueSignal.Reset(); + imageRef = null; + return false; + } + + // SortedDictionary enumerator yields items in key order + // (lowest priority value first, then lowest insertion order). + using (var enumerator = mQueue.GetEnumerator()) + { + enumerator.MoveNext(); + var key = enumerator.Current.Key; + imageRef = enumerator.Current.Value; + mQueue.Remove(key); + mQueueIndex.Remove(imageRef); + } + + return true; + } + } + + void PostResolvedImage(ImageReference imageRef, IImage resolved) + { + if (resolved == null) + return; + + // Collect ALL object instances that share this equality key. + // Only one instance enters the queue, but many may exist (e.g. + // 10 items using the same small-key icon). Every instance's + // ResolvedImage must be set so their bindings update. + var instances = TakePendingInstances(imageRef); + + // ResolvedImage must be set on the UI thread so that + // PropertyChanged fires there and Avalonia bindings update. + Dispatcher.UIThread.Post(() => + { + if (instances != null) + { + foreach (var instance in instances) + instance.ResolvedImage = resolved; + } + else + { + // Fallback: set on the specific object that was dequeued + imageRef.ResolvedImage = resolved; + } + }); + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs index b03e0ea..fe3f7a4 100644 --- a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs @@ -1,46 +1,111 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; -using EmoTracker.Data; -using EmoTracker.Data.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - public class ConcreteImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as ConcreteImageReference != null; - } - - public override ImageSource ResolveReference(ImageReference imageRef) - { - ConcreteImageReference concreteRef = imageRef as ConcreteImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.URI == null) - return null; - - if (concreteRef.URI.Scheme.Equals("gamepackage", StringComparison.OrdinalIgnoreCase)) - { - if (Tracker.Instance.ActiveGamePackage == null) - return null; - - using (Stream s = Tracker.Instance.ActiveGamePackage.Open(string.Format("{0}{1}", Uri.UnescapeDataString(concreteRef.URI.Host), Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)))) - { - if (s == null) - return null; - - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, Utility.IconUtility.GetImage(s), concreteRef.Filter); - } - } - - return Utility.IconUtility.GetImageRaw(concreteRef.URI); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + public class ConcreteImageReferenceResolver : ImageReferenceResolver + { + /// + /// Cache of decoded base SKBitmaps keyed by the pack-relative file path. + /// Multiple instances that point to + /// the same source image but with different filters share the decoded + /// base bitmap. Cleared on pack unload via . + /// + static readonly Dictionary sSourceCache + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Clears the source image cache. Called by + /// on pack unload. + /// + public static void ClearSourceCache() + { + lock (sSourceCache) + { + foreach (var kvp in sSourceCache) + kvp.Value?.Dispose(); + sSourceCache.Clear(); + } + } + + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as ConcreteImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + ConcreteImageReference concreteRef = imageRef as ConcreteImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.URI == null) + return null; + + if (concreteRef.URI.Scheme.Equals("gamepackage", StringComparison.OrdinalIgnoreCase)) + { + if (Tracker.Instance.ActiveGamePackage == null) + return null; + + string filePath = string.Format("{0}{1}", + Uri.UnescapeDataString(concreteRef.URI.Host), + Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)); + + // Get the decoded base SKBitmap from cache, or decode it + SKBitmap baseSK = GetCachedSource(filePath); + if (baseSK == null) + { + using (Stream s = Tracker.Instance.ActiveGamePackage.Open(filePath)) + { + if (s == null) + return null; + + baseSK = Utility.IconUtility.DecodeSKBitmap(s); + } + + if (baseSK == null) + return null; + + PutCachedSource(filePath, baseSK); + } + + // Clone the base bitmap so filter operations don't mutate the + // cached original, then run the entire filter chain in SKBitmap + // space (no intermediate PNG round-trips). + SKBitmap working = baseSK.Copy(); + working = Utility.IconUtility.ApplyFilterSpecToSKBitmap( + Tracker.Instance.ActiveGamePackage, working, concreteRef.Filter); + + // Convert to Avalonia IImage once at the end, computing the + // alpha mask for InputMaskingImage hit-testing. + return Utility.IconUtility.FinalizeToAvalonia(working); + } + + return Utility.IconUtility.GetImageRaw(concreteRef.URI); + } + + static SKBitmap GetCachedSource(string filePath) + { + lock (sSourceCache) + { + if (sSourceCache.TryGetValue(filePath, out SKBitmap bmp)) + return bmp; + return null; + } + } + + static void PutCachedSource(string filePath, SKBitmap bmp) + { + lock (sSourceCache) + { + sSourceCache[filePath] = bmp; + } + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs index 6ca4b54..5940a6d 100644 --- a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs @@ -1,24 +1,38 @@ -using EmoTracker.Data; -using EmoTracker.Data.Media; -using System.Windows.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class FilterImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as FilterImageReference != null; - } - - public override ImageSource ResolveReference(ImageReference imageRef) - { - FilterImageReference concreteRef = imageRef as FilterImageReference; - if (concreteRef == null) - return null; - - ImageSource baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + class FilterImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as FilterImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + FilterImageReference concreteRef = imageRef as FilterImageReference; + if (concreteRef == null) + return null; + + var baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); + if (baseImg == null) + return null; + + // Convert the resolved base IImage to SKBitmap, apply the filter + // chain entirely in SKBitmap space, then convert back once. + SKBitmap baseSK = Utility.IconUtility.ToSkBitmapForFilter(baseImg); + if (baseSK == null) + return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); + + baseSK = Utility.IconUtility.ApplyFilterSpecToSKBitmap( + Tracker.Instance.ActiveGamePackage, baseSK, concreteRef.Filter); + + return Utility.IconUtility.FinalizeToAvalonia(baseSK); + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs index 219f6c7..1fa3816 100644 --- a/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs @@ -1,5 +1,6 @@ -using EmoTracker.Data.Media; -using System.Windows.Media; +using EmoTracker.Data.Media; + +using Avalonia.Media; namespace EmoTracker.UI.Media.Resolvers { @@ -7,6 +8,6 @@ public abstract class ImageReferenceResolver { public abstract bool CanResolveReference(ImageReference imageRef); - public abstract ImageSource ResolveReference(ImageReference imageRef); + public abstract IImage ResolveReference(ImageReference imageRef); } } diff --git a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs index 9efbe87..8b14494 100644 --- a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs @@ -1,40 +1,58 @@ -using EmoTracker.Data.Media; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class LayeredImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as LayeredImageReference != null; - } - - public override ImageSource ResolveReference(ImageReference imageRef) - { - LayeredImageReference concreteRef = imageRef as LayeredImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.Layers.Count == 0) - return null; - - ImageSource img = null; - foreach (ImageReference layerRef in concreteRef.Layers) - { - ImageSource layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); - img = Utility.IconUtility.ApplyOverlayImage(img, layerImg); - } - - if (img != null) - img.Freeze(); - - return img; - } - } -} +using EmoTracker.Data.Media; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + class LayeredImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as LayeredImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + LayeredImageReference concreteRef = imageRef as LayeredImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.Layers.Count == 0) + return null; + + // Composite all layers in SKBitmap space — one PNG conversion at the end + // instead of N round-trips through PNG encode→decode per layer. + SKBitmap composite = null; + foreach (ImageReference layerRef in concreteRef.Layers) + { + var layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); + if (layerImg == null) + continue; + + SKBitmap layerSK = Utility.IconUtility.ToSkBitmapForFilter(layerImg); + if (layerSK == null) + continue; + + if (composite == null) + { + composite = layerSK; + } + else + { + var prev = composite; + composite = Utility.IconUtility.ApplyOverlaySK(composite, layerSK); + // ApplyOverlaySK disposes overlay (layerSK) and may return a new + // bitmap; dispose the old composite if it changed. + if (composite != prev) + prev.Dispose(); + } + } + + if (composite == null) + return null; + + return Utility.IconUtility.FinalizeToAvalonia(composite); + } + } +} diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index 56acc6b..aefd175 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -1,494 +1,738 @@ -using EmoTracker.Core; -using EmoTracker.Data; -using System; -using System.IO; -using System.Linq; -using System.Net.Cache; -using System.Windows.Media; -using System.Windows.Media.Imaging; - -namespace EmoTracker.UI.Media.Utility -{ - public class IconUtility : ObservableSingleton - { - private bool mbEnableDpiConversion = true; - - public bool EnableDpiConversion - { - get { return mbEnableDpiConversion; } - set { SetProperty(ref mbEnableDpiConversion, value); } - } - - - private static RequestCachePolicy RawImageRequestPolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache); - - public static ImageSource GetImageRaw(Uri uri) - { - try - { - return new BitmapImage(uri) - { - UriCachePolicy = RawImageRequestPolicy - }; - } - catch - { - return null; - } - } - - public static ImageSource GetImage(Uri uri) - { - try - { - FormatConvertedBitmap srcImg = new FormatConvertedBitmap(); - srcImg.BeginInit(); - srcImg.DestinationFormat = PixelFormats.Bgra32; - srcImg.Source = new BitmapImage(uri); - srcImg.EndInit(); - - WriteableBitmap bmp = new WriteableBitmap(srcImg); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - - if (r == 255 && g == 0 && b == 255) - { - buffer[(y * bmp.BackBufferStride + x * 4) + 3] = 0; - } - } - } - - if (IconUtility.Instance.EnableDpiConversion) - { - // Neutralize all images to 96dpi, which is the internal WPF standard - BitmapSource result = BitmapSource.Create(bmp.PixelWidth, bmp.PixelHeight, 96, 96, bmp.Format, bmp.Palette, buffer, bmp.BackBufferStride); - if (result != null) - result.Freeze(); - - return result; - } - else - { - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - return bmp; - } - } - catch - { - return null; - } - - } - - public static ImageSource GetImage(Stream stream) - { - if (stream == null) - return null; - - try - { - BitmapImage baseImage = new BitmapImage(); - baseImage.BeginInit(); - baseImage.StreamSource = stream; - baseImage.CacheOption = BitmapCacheOption.OnLoad; - baseImage.EndInit(); - baseImage.Freeze(); - - FormatConvertedBitmap srcImg = new FormatConvertedBitmap(); - srcImg.BeginInit(); - srcImg.DestinationFormat = PixelFormats.Bgra32; - srcImg.Source = baseImage; - srcImg.EndInit(); - - WriteableBitmap bmp = new WriteableBitmap(srcImg); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - - if (r == 255 && g == 0 && b == 255) - { - buffer[(y * bmp.BackBufferStride + x * 4) + 3] = 0; - } - } - } - - if (IconUtility.Instance.EnableDpiConversion) - { - // Neutralize all images to 96dpi, which is the internal WPF standard - BitmapSource result = BitmapSource.Create(bmp.PixelWidth, bmp.PixelHeight, 96, 96, bmp.Format, bmp.Palette, buffer, bmp.BackBufferStride); - if (result != null) - result.Freeze(); - - return result; - } - else - { - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - return bmp; - } - } - catch - { - return null; - } - } - - public static ImageSource ApplyOverlayImage(IGamePackage package, ImageSource image, params string[] args) - { - if (package == null) - return image; - - if (args.Length >= 1) - { - ImageSource overlay = GetImage(package.Open(args[0])); - if (overlay != null) - return ApplyOverlayImage(image, overlay); - } - - return image; - } - - public static ImageSource ApplyOverlayImage(ImageSource image, ImageSource overlay) - { - if (overlay == null) - return image; - - if (image == null) - return overlay; - - if (image == null && overlay == null) - return null; - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - WriteableBitmap overlayBMP = new WriteableBitmap((BitmapSource)overlay); - - if (overlayBMP.PixelWidth != bmp.PixelWidth || overlayBMP.PixelHeight != bmp.PixelHeight) - { - ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); - return image; - } - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - byte[] overlayBuffer = new byte[overlayBMP.PixelHeight * overlayBMP.PixelWidth * 4]; - overlayBMP.CopyPixels(overlayBuffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - byte srcAlpha = buffer[(y * bmp.BackBufferStride + x * 4) + 3]; - - byte ob = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 0]; - byte og = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 1]; - byte or = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 2]; - byte oa = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 3]; - - float alpha = oa / 255.0f; - float invAlpha = 1.0f - alpha; - - b = (byte)Math.Min(Math.Max((uint)((uint)ob * alpha + (uint)b * invAlpha), 0), 255); - g = (byte)Math.Min(Math.Max((uint)((uint)og * alpha + (uint)g * invAlpha), 0), 255); - r = (byte)Math.Min(Math.Max((uint)((uint)or * alpha + (uint)r * invAlpha), 0), 255); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - buffer[(y * bmp.BackBufferStride + x * 4) + 3] = Math.Max(srcAlpha, oa); - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - - private static byte Lerp(byte a, byte b, float factor) - { - if (factor <= 0.0f) - return a; - - if (factor >= 1.0f) - return b; - - float raw = ((float)a * (1.0f - factor)) + ((float)b * factor); - byte value = (byte)(raw + 0.5f); - - return value; - } - - public enum LuminanceMode - { - Avg, - Average, - Max, - Blue, - Green, - Red, - BT709, - BT601 - } - - public static ImageSource MakeImageGrayscale(ImageSource image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) - { - if (image == null) - return null; - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - - byte bo = b; - byte go = g; - byte ro = r; - - switch (mode) - { - case LuminanceMode.Avg: - case LuminanceMode.Average: - b = g = r = (byte)((b + g + r) / 3.0); - break; - - case LuminanceMode.Max: - b = g = r = Math.Max(Math.Max(b, g), r); - break; - - case LuminanceMode.Blue: - g = r = b; - break; - - case LuminanceMode.Green: - b = r = g; - break; - - case LuminanceMode.Red: - b = g = r; - break; - - case LuminanceMode.BT709: - b = g = r = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) / 6); - break; - - case LuminanceMode.BT601: - b = g = r = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) >> 3); - break; - } - - - - // b = g = r = Math.Max(Math.Max(b, g), r); - // - - b = Lerp(b, bo, saturation); - g = Lerp(g, go, saturation); - r = Lerp(r, ro, saturation); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - - public static ImageSource AdjustSaturation(IGamePackage package, ImageSource image, params string[] args) - { - if (image == null) - return null; - - if (args.Length >= 1) - { - float saturation; - if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out saturation)) - { - LuminanceMode mode = LuminanceMode.Average; - if (args.Length >= 2) - Enum.TryParse(args[1], true, out mode); - - saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); - return MakeImageGrayscale(image, mode, saturation); - } - } - - return image; - } - - - public static ImageSource AdjustBrightness(IGamePackage package, ImageSource image, params string[] args) - { - if (image == null) - return null; - - if (args.Length >= 1) - { - float brightness; - if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out brightness)) - { - brightness = Math.Max(brightness, 0.0f); - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = (byte)(Math.Min(255.0f, Math.Max(0.0f, (buffer[(y * bmp.BackBufferStride + x * 4) + 0] * brightness)))); - byte g = (byte)(Math.Min(255.0f, Math.Max(0.0f, (buffer[(y * bmp.BackBufferStride + x * 4) + 1] * brightness)))); - byte r = (byte)(Math.Min(255.0f, Math.Max(0.0f, (buffer[(y * bmp.BackBufferStride + x * 4) + 2] * brightness)))); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - } - - return image; - } - - public static ImageSource MakeImageDim(ImageSource image, int divisor = 2) - { - if (image == null) - return null; - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 0] - buffer[(y * bmp.BackBufferStride + x * 4) + 0] / divisor); - byte g = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 1] - buffer[(y * bmp.BackBufferStride + x * 4) + 1] / divisor); - byte r = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 2] - buffer[(y * bmp.BackBufferStride + x * 4) + 2] / divisor); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - - public static ImageSource ApplyFilterSpecToImage(IGamePackage package, ImageSource image, string filterSpec) - { - if (image == null) - return null; - - if (!string.IsNullOrWhiteSpace(filterSpec)) - { - string[] mods = filterSpec.Split(','); - foreach (string modRaw in mods) - { - string[] tokens = GetArgs(modRaw); - if (tokens.Length >= 1) - { - string mod = tokens[0]; - string[] args = tokens.Skip(1).ToArray(); - - if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.MakeImageGrayscale(image); - } - else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.MakeImageDim(image); - } - else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.MakeImageDim(image, 4); - } - else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.MakeImageDim(image, 8); - } - else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.AdjustBrightness(package, image, args); - } - else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.ApplyOverlayImage(package, image, args); - } - else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.AdjustSaturation(package, image, args); - } - else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) - { - image = IconUtility.ApplyFilterSpecToImage(package, image, Tracker.Instance.DisabledImageFilterSpec); - } - } - } - } - - if (image != null) - image.Freeze(); - - return image; - } - - public static string[] GetArgs(string filterCommand) - { - string[] args = filterCommand.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < args.Length; ++i) - { - args[i] = args[i].Trim(); - } - - return args; - } - } -} +#nullable enable annotations +using EmoTracker.Core; +using EmoTracker.Data; +using System; +using System.IO; +using System.Linq; + +using Avalonia.Media; +using Avalonia.Media.Imaging; +using SkiaSharp; +using System.Collections.Generic; + +namespace EmoTracker.UI.Media.Utility +{ + public class IconUtility : ObservableSingleton + { + private bool mbEnableDpiConversion = true; + + public bool EnableDpiConversion + { + get { return mbEnableDpiConversion; } + set { SetProperty(ref mbEnableDpiConversion, value); } + } + + public enum LuminanceMode + { + Avg, + Average, + Max, + Blue, + Green, + Red, + BT709, + BT601 + } + + public static string[] GetArgs(string filterCommand) + { + string[] args = filterCommand.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < args.Length; ++i) + args[i] = args[i].Trim(); + return args; + } + + // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── + + // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque. + // ConcurrentDictionary because the background image worker writes masks while + // the UI thread reads them (InputMaskingImage.HitTest via GetAlphaMask). + private static readonly System.Collections.Concurrent.ConcurrentDictionary sAlphaMasks = new(); + + // Cached Skia-encoded PNG bytes for each IImage produced by SkToAvalonia. + // Used by ToSkBitmap to bypass Avalonia's Bitmap.Save(Stream), which may strip the + // alpha channel on some platforms — causing overlay compositing to treat every pixel + // as fully opaque and completely hide the base layer. + // ConcurrentDictionary because the background image worker writes entries while + // filter resolution may read them concurrently. + private static readonly System.Collections.Concurrent.ConcurrentDictionary sPngCache = new(); + + // HTTP/HTTPS image download cache. null value means "download in progress". + private static readonly System.Collections.Concurrent.ConcurrentDictionary sHttpCache = new(); + private static readonly System.Net.Http.HttpClient sHttpClient = new(); + + /// + /// Raised on the UI thread after an HTTP image finishes downloading. + /// Subscribers (e.g. ApplicationModel) can use this to refresh bindings. + /// + public static event EventHandler? HttpImageLoaded; + + /// Returns the precomputed alpha mask for an image, or null if not available. + public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) + { + if (image != null && sAlphaMasks.TryGetValue(image, out var entry)) + return entry; + return null; + } + + // ── SKBitmap decode / promote ─────────────────────────────────────────── + + /// + /// Decode a stream to an SKBitmap, promote to Bgra8888/Premul, and apply + /// the magenta colour-key. This is the internal entry point for the + /// SKBitmap filter pipeline — callers get an SKBitmap they can pass through + /// without any PNG round-trips. + /// + internal static SKBitmap DecodeSKBitmap(Stream stream) + { + if (stream == null) + return null; + try + { + SKBitmap decoded = SKBitmap.Decode(stream); + if (decoded == null) return null; + + // Promote to Bgra8888/Premul. + SKBitmap bmp; + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) + { + var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); + bmp = new SKBitmap(targetInfo); + using (var cvs = new SKCanvas(bmp)) + { + cvs.Clear(SKColors.Transparent); + cvs.DrawBitmap(decoded, 0, 0); + } + decoded.Dispose(); + } + else + { + bmp = decoded; + } + + // Apply color key: magenta (R=255, G=0, B=255) → transparent. + // Uses direct pixel buffer access for ~10-50× speedup over GetPixel/SetPixel. + ApplyColorKey(bmp); + + return bmp; + } + catch { return null; } + } + + /// + /// Replace magenta (255, 0, 255) pixels with transparent using direct pixel + /// buffer access. The bitmap must be Bgra8888. + /// + private static unsafe void ApplyColorKey(SKBitmap bmp) + { + int pixelCount = bmp.Width * bmp.Height; + uint* pixels = (uint*)bmp.GetPixels().ToPointer(); + + for (int i = 0; i < pixelCount; i++) + { + uint c = pixels[i]; + // BGRA8888 little-endian: B=byte0, G=byte1, R=byte2, A=byte3 + // As uint32: 0xAARRGGBB + byte blue = (byte)(c); + byte green = (byte)(c >> 8); + byte red = (byte)(c >> 16); + + if (red == 255 && green == 0 && blue == 255) + pixels[i] = 0; // fully transparent + } + } + + /// + /// Compute the alpha mask from an SKBitmap using direct pixel buffer access. + /// + private static unsafe bool[] ComputeAlphaMask(SKBitmap bmp) + { + int pixelCount = bmp.Width * bmp.Height; + var mask = new bool[pixelCount]; + uint* pixels = (uint*)bmp.GetPixels().ToPointer(); + + for (int i = 0; i < pixelCount; i++) + { + byte alpha = (byte)(pixels[i] >> 24); + mask[i] = alpha >= 10; + } + + return mask; + } + + // ── IImage ↔ SKBitmap conversion ──────────────────────────────────────── + + /// + /// Convert an Avalonia IImage to an SKBitmap for use in the filter pipeline. + /// Returns null if conversion fails. The caller owns the returned bitmap. + /// + internal static SKBitmap ToSkBitmapForFilter(IImage image) => ToSkBitmap(image); + + /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. + /// + /// Uses the Skia-encoded PNG bytes cached by when available, + /// avoiding Bitmap.Save(Stream) which may drop the alpha channel on some platforms. + /// Always returns a Bgra8888 bitmap. + /// + private static SKBitmap ToSkBitmap(IImage image) + { + if (image is not Avalonia.Media.Imaging.Bitmap avBitmap) + return null; + try + { + Stream pngStream; + if (sPngCache.TryGetValue(avBitmap, out byte[] cachedBytes)) + { + pngStream = new MemoryStream(cachedBytes, writable: false); + } + else + { + var ms = new MemoryStream(); + avBitmap.Save(ms); + ms.Position = 0; + pngStream = ms; + } + + using (pngStream) + { + SKBitmap decoded = SKBitmap.Decode(pngStream); + if (decoded == null) return null; + + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) + { + var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); + SKBitmap promoted = new SKBitmap(targetInfo); + using (var cvs = new SKCanvas(promoted)) + { + cvs.Clear(SKColors.Transparent); + cvs.DrawBitmap(decoded, 0, 0); + } + decoded.Dispose(); + return promoted; + } + return decoded; + } + } + catch { return null; } + } + + /// + /// Convert an SKBitmap to an Avalonia IImage, computing and caching its + /// alpha mask for InputMaskingImage hit-testing. Disposes the input bitmap. + /// This is the single conversion point at the END of the SKBitmap pipeline. + /// + internal static IImage FinalizeToAvalonia(SKBitmap bmp) + { + if (bmp == null) return null; + try + { + var mask = ComputeAlphaMask(bmp); + var avBitmap = SkToAvaloniaCore(bmp); + sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + bmp.Dispose(); + return avBitmap; + } + catch + { + bmp?.Dispose(); + return null; + } + } + + /// Convert an SKBitmap to an Avalonia IImage, optionally caching its alpha mask. + private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) + { + var avBitmap = SkToAvaloniaCore(bmp); + + if (storeMask) + { + var mask = ComputeAlphaMask(bmp); + sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + } + + return avBitmap; + } + + /// + /// Core SKBitmap → Avalonia Bitmap conversion. Encodes as PNG and caches + /// the PNG bytes so ToSkBitmap can round-trip without Bitmap.Save. + /// + private static Avalonia.Media.Imaging.Bitmap SkToAvaloniaCore(SKBitmap bmp) + { + using var skImg = SKImage.FromBitmap(bmp); + using var encoded = skImg.Encode(SKEncodedImageFormat.Png, 100); + byte[] pngBytes = encoded.ToArray(); + var avBitmap = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(pngBytes)); + + sPngCache[avBitmap] = pngBytes; + + return avBitmap; + } + + // ── SKBitmap-based filter operations ──────────────────────────────────── + // + // These operate entirely in SKBitmap space. Skia's colour-matrix filter + // runs natively (potentially SIMD-optimised) and is orders of magnitude + // faster than per-pixel GetPixel/SetPixel loops. + // + // The caller is responsible for disposing the INPUT bitmap if it differs + // from the returned bitmap (the ApplyFilterSpecToSKBitmap method does this + // automatically for chained filters). + + /// Apply a 4×5 colour matrix to an SKBitmap via Skia's native path. + private static SKBitmap ApplyColorMatrix(SKBitmap src, float[] matrix) + { + var result = new SKBitmap(src.Info); + using var canvas = new SKCanvas(result); + canvas.Clear(SKColors.Transparent); + using var filter = SKColorFilter.CreateColorMatrix(matrix); + using var paint = new SKPaint + { + ColorFilter = filter, + BlendMode = SKBlendMode.Src // overwrite destination, don't composite + }; + canvas.DrawBitmap(src, 0, 0, paint); + return result; + } + + /// + /// Build the 4×5 colour matrix for a grayscale+saturation operation. + /// When is 0 the result is fully desaturated; + /// when 1 the image is unchanged. + /// + private static float[] ComputeGrayscaleMatrix(LuminanceMode mode, float saturation) + { + // Luminance weights per mode — determines how RGB channels + // contribute to the grayscale value. + float wr, wg, wb; + switch (mode) + { + case LuminanceMode.Max: + // Max-luminance can't be expressed as a linear matrix. + // Fall back to equal weights as a reasonable approximation. + wr = wg = wb = 1f / 3f; + break; + case LuminanceMode.Blue: + wr = 0; wg = 0; wb = 1; + break; + case LuminanceMode.Green: + wr = 0; wg = 1; wb = 0; + break; + case LuminanceMode.Red: + wr = 1; wg = 0; wb = 0; + break; + case LuminanceMode.BT709: + // The original code uses (2R+3G+B)/6 which is closer to BT.601 + wr = 2f / 6f; wg = 3f / 6f; wb = 1f / 6f; + break; + case LuminanceMode.BT601: + // Original uses (2R+3G+B)>>3 — divides by 8, not 6. + // Weights intentionally sum to 0.75, producing a dimmer result. + wr = 2f / 8f; wg = 3f / 8f; wb = 1f / 8f; + break; + default: // Average, Avg + wr = wg = wb = 1f / 3f; + break; + } + + // Saturation matrix: lerp between grayscale and identity. + // out_r = r * (wr*(1-s) + s) + g * wg*(1-s) + b * wb*(1-s) + // (and analogously for g, b) + float s = saturation; + float inv = 1f - s; + + return new float[] + { + wr * inv + s, wg * inv, wb * inv, 0, 0, + wr * inv, wg * inv + s, wb * inv, 0, 0, + wr * inv, wg * inv, wb * inv + s, 0, 0, + 0, 0, 0, 1, 0 + }; + } + + internal static SKBitmap MakeGrayscaleSK(SKBitmap src, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + return ApplyColorMatrix(src, ComputeGrayscaleMatrix(mode, saturation)); + } + + internal static SKBitmap MakeDimSK(SKBitmap src, int divisor = 2) + { + // Original: r = r - r/divisor → factor = 1 - 1/divisor + float factor = 1.0f - 1.0f / divisor; + float[] matrix = + { + factor, 0, 0, 0, 0, + 0, factor, 0, 0, 0, + 0, 0, factor, 0, 0, + 0, 0, 0, 1, 0 + }; + return ApplyColorMatrix(src, matrix); + } + + internal static SKBitmap AdjustBrightnessSK(SKBitmap src, float brightness) + { + float[] matrix = + { + brightness, 0, 0, 0, 0, + 0, brightness, 0, 0, 0, + 0, 0, brightness, 0, 0, + 0, 0, 0, 1, 0 + }; + return ApplyColorMatrix(src, matrix); + } + + /// + /// Composite an overlay bitmap on top of a base bitmap using Skia's + /// built-in SrcOver blend mode. Disposes the overlay; returns a new bitmap. + /// The base bitmap is NOT disposed (caller manages it). + /// + internal static SKBitmap ApplyOverlaySK(SKBitmap baseBmp, SKBitmap overlay) + { + if (overlay == null) return baseBmp; + if (baseBmp == null) return overlay; + + if (baseBmp.Width != overlay.Width || baseBmp.Height != overlay.Height) + { + ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); + overlay.Dispose(); + return baseBmp; + } + + // Clone base and draw overlay on top with SrcOver blend + var result = baseBmp.Copy(); + using var canvas = new SKCanvas(result); + using var paint = new SKPaint { BlendMode = SKBlendMode.SrcOver }; + canvas.DrawBitmap(overlay, 0, 0, paint); + overlay.Dispose(); + return result; + } + + /// + /// Apply the full filter specification, staying in SKBitmap space for the + /// entire chain. Each filter step produces a new SKBitmap; intermediates + /// are disposed automatically. The INPUT bitmap is consumed (may be + /// disposed or returned). + /// + internal static SKBitmap ApplyFilterSpecToSKBitmap(IGamePackage package, SKBitmap bmp, string filterSpec) + { + if (bmp == null) + return null; + + if (!string.IsNullOrWhiteSpace(filterSpec)) + { + string[] mods = filterSpec.Split(','); + foreach (string modRaw in mods) + { + string[] tokens = GetArgs(modRaw); + if (tokens.Length >= 1) + { + string mod = tokens[0]; + string[] args = tokens.Skip(1).ToArray(); + + SKBitmap prev = bmp; + + if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) + bmp = MakeGrayscaleSK(bmp); + else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp); + else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp, 4); + else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp, 8); + else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length >= 1 && + float.TryParse(args[0], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float brightness)) + { + brightness = Math.Max(brightness, 0.0f); + bmp = AdjustBrightnessSK(bmp, brightness); + } + } + else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) + { + if (package != null && args.Length >= 1) + { + SKBitmap overlay = DecodeSKBitmap(package.Open(args[0])); + if (overlay != null) + bmp = ApplyOverlaySK(bmp, overlay); + } + } + else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length >= 1 && + float.TryParse(args[0], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float saturation)) + { + LuminanceMode mode = LuminanceMode.Average; + if (args.Length >= 2) + Enum.TryParse(args[1], true, out mode); + saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); + bmp = MakeGrayscaleSK(bmp, mode, saturation); + } + } + else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) + bmp = ApplyFilterSpecToSKBitmap(package, bmp, Tracker.Instance.DisabledImageFilterSpec); + + // Dispose the intermediate bitmap if a new one was produced + if (bmp != prev) + prev.Dispose(); + } + } + } + + return bmp; + } + + // ── Legacy IImage-based API (backward compat) ─────────────────────────── + + /// + /// Translates a WPF pack://application:,,,/AssemblyName;component/path URI + /// to the Avalonia equivalent avares://AssemblyName/path. + /// Returns the original URI unchanged for all other schemes. + /// + private static Uri TranslatePackUri(Uri uri) + { + const string packPrefix = "pack://application:,,,/"; + string orig = uri.OriginalString; + if (!orig.StartsWith(packPrefix, StringComparison.OrdinalIgnoreCase)) + return uri; + string rest = orig.Substring(packPrefix.Length); + int compIdx = rest.IndexOf(";component/", StringComparison.OrdinalIgnoreCase); + if (compIdx < 0) return uri; + string assembly = rest.Substring(0, compIdx); + string path = rest.Substring(compIdx + ";component".Length); // includes leading / + return new Uri($"avares://{assembly}{path}"); + } + + public static IImage GetImageRaw(Uri uri) + { + try + { + Uri resolved = TranslatePackUri(uri); + if (resolved.Scheme == "avares") + { + using var stream = Avalonia.Platform.AssetLoader.Open(resolved); + return new Avalonia.Media.Imaging.Bitmap(stream); + } + if (resolved.IsFile) + return new Avalonia.Media.Imaging.Bitmap(resolved.LocalPath); + if (resolved.Scheme == "http" || resolved.Scheme == "https") + return GetImageFromHttp(resolved); + return null; + } + catch { return null; } + } + + private static IImage? GetImageFromHttp(Uri uri) + { + string key = uri.AbsoluteUri; + if (sHttpCache.TryGetValue(key, out IImage? cached)) + return cached; // null if still loading, non-null if loaded + + sHttpCache[key] = null; + + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + byte[] bytes = await sHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); + using var ms = new System.IO.MemoryStream(bytes); + sHttpCache[key] = new Avalonia.Media.Imaging.Bitmap(ms); + } + catch + { + sHttpCache.TryRemove(key, out _); + } + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + HttpImageLoaded?.Invoke(null, EventArgs.Empty)); + }); + + return null; + } + + public static IImage GetImage(Uri uri) + { + try + { + Uri resolved = TranslatePackUri(uri); + if (resolved.Scheme == "avares") + { + using var stream = Avalonia.Platform.AssetLoader.Open(resolved); + return GetImage(stream); + } + if (resolved.IsFile) + { + using var stream = File.OpenRead(resolved.LocalPath); + return GetImage(stream); + } + return null; + } + catch { return null; } + } + + /// + /// Decode a stream to an Avalonia IImage with colour-key and alpha mask. + /// Kept for backward compatibility; new resolver code should prefer + /// + . + /// + public static IImage GetImage(Stream stream) + { + SKBitmap bmp = DecodeSKBitmap(stream); + if (bmp == null) return null; + + var result = SkToAvalonia(bmp, storeMask: true); + bmp.Dispose(); + return result; + } + + // ── Legacy IImage filter wrappers ─────────────────────────────────────── + // These are kept for any call sites that still pass IImage. They convert + // to SKBitmap, apply the fast SKBitmap filter, convert back. + + public static IImage ApplyOverlayImage(IGamePackage package, IImage image, params string[] args) + { + if (package == null) + return image; + + if (args.Length >= 1) + { + IImage overlay = GetImage(package.Open(args[0])); + if (overlay != null) + return ApplyOverlayImage(image, overlay); + } + + return image; + } + + public static IImage ApplyOverlayImage(IImage image, IImage overlay) + { + if (overlay == null) return image; + if (image == null) return overlay; + + try + { + SKBitmap baseBmp = ToSkBitmap(image); + SKBitmap overlayBmp = ToSkBitmap(overlay); + + if (baseBmp == null) return image; + if (overlayBmp == null) { baseBmp.Dispose(); return image; } + + var result = ApplyOverlaySK(baseBmp, overlayBmp); + + // overlayBmp disposed by ApplyOverlaySK; baseBmp is not + if (result != baseBmp) + baseBmp.Dispose(); + + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + catch { return image; } + } + + public static IImage MakeImageGrayscale(IImage image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + if (image == null) return null; + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = MakeGrayscaleSK(bmp, mode, saturation); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + + public static IImage AdjustSaturation(IGamePackage package, IImage image, params string[] args) + { + if (image == null) return null; + if (args.Length >= 1) + { + if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float saturation)) + { + LuminanceMode mode = LuminanceMode.Average; + if (args.Length >= 2) + Enum.TryParse(args[1], true, out mode); + saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); + return MakeImageGrayscale(image, mode, saturation); + } + } + return image; + } + + public static IImage AdjustBrightness(IGamePackage package, IImage image, params string[] args) + { + if (image == null) return null; + if (args.Length >= 1) + { + if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float brightness)) + { + brightness = Math.Max(brightness, 0.0f); + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = AdjustBrightnessSK(bmp, brightness); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + } + return image; + } + + public static IImage MakeImageDim(IImage image, int divisor = 2) + { + if (image == null) return null; + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = MakeDimSK(bmp, divisor); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + + /// + /// Legacy IImage-based filter chain. Kept for backward compatibility. + /// Resolvers should prefer the SKBitmap pipeline. + /// + public static IImage ApplyFilterSpecToImage(IGamePackage package, IImage image, string filterSpec) + { + if (image == null) + return null; + + if (!string.IsNullOrWhiteSpace(filterSpec)) + { + string[] mods = filterSpec.Split(','); + foreach (string modRaw in mods) + { + string[] tokens = GetArgs(modRaw); + if (tokens.Length >= 1) + { + string mod = tokens[0]; + string[] args = tokens.Skip(1).ToArray(); + + if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageGrayscale(image); + else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image); + else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image, 4); + else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image, 8); + else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.AdjustBrightness(package, image, args); + else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.ApplyOverlayImage(package, image, args); + else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.AdjustSaturation(package, image, args); + else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.ApplyFilterSpecToImage(package, image, Tracker.Instance.DisabledImageFilterSpec); + } + } + } + + return image; + } + } +} diff --git a/EmoTracker.UI/PreserveDimension.cs b/EmoTracker.UI/PreserveDimension.cs new file mode 100644 index 0000000..b697da4 --- /dev/null +++ b/EmoTracker.UI/PreserveDimension.cs @@ -0,0 +1,9 @@ +namespace EmoTracker.UI +{ + public enum PreserveDimension + { + None, + Width, + Height + } +} diff --git a/EmoTracker.UI/Properties/AssemblyInfo.cs b/EmoTracker.UI/Properties/AssemblyInfo.cs index 693fccb..541481a 100644 --- a/EmoTracker.UI/Properties/AssemblyInfo.cs +++ b/EmoTracker.UI/Properties/AssemblyInfo.cs @@ -1,55 +1,46 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Windows; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("EmoTracker.UI")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("EmoTracker.UI")] -[assembly: AssemblyCopyright("Copyright © 2019")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -//In order to begin building localizable applications, set -//CultureYouAreCodingWith in your .csproj file -//inside a . For example, if you are using US english -//in your source files, set the to en-US. Then uncomment -//the NeutralResourceLanguage attribute below. Update the "en-US" in -//the line below to match the UICulture setting in the project file. - -//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] - - -[assembly:ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] - - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("EmoTracker.UI")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("EmoTracker.UI")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +//In order to begin building localizable applications, set +//CultureYouAreCodingWith in your .csproj file +//inside a . For example, if you are using US english +//in your source files, set the to en-US. Then uncomment +//the NeutralResourceLanguage attribute below. Update the "en-US" in +//the line below to match the UICulture setting in the project file. + +//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] + + + + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("3.0.1.11")] +[assembly: AssemblyVersion("3.0.1.11")] +[assembly: AssemblyFileVersion("3.0.1.11")] diff --git a/EmoTracker.sln b/EmoTracker.sln index 9feafa1..94ad9ff 100644 --- a/EmoTracker.sln +++ b/EmoTracker.sln @@ -8,10 +8,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmoTracker", "EmoTracker\Em {4565F6D4-BB80-407C-B926-79F62F03CFA6} = {4565F6D4-BB80-407C-B926-79F62F03CFA6} EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NDILibDotNet2", "External\NDI\NDILibDotNet2\NDILibDotNet2.csproj", "{7195BFA6-8095-4618-96F6-8577DB13FB43}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "External", "External", "{E01A632C-E967-4695-AAA3-790FCBF94E5B}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmoTracker.Core", "EmoTracker.Core\EmoTracker.Core.csproj", "{49C7D6E4-54AC-40D8-865D-F242C0E1247C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmoTracker.UI", "EmoTracker.UI\EmoTracker.UI.csproj", "{4565F6D4-BB80-407C-B926-79F62F03CFA6}" @@ -58,30 +54,6 @@ Global {37EF8519-4426-455F-9D23-292839365EE0}.Release|x64.Build.0 = Release|x64 {37EF8519-4426-455F-9D23-292839365EE0}.Release|x86.ActiveCfg = Release|Any CPU {37EF8519-4426-455F-9D23-292839365EE0}.Release|x86.Build.0 = Release|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Beta|Any CPU.ActiveCfg = Beta|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Beta|Any CPU.Build.0 = Beta|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Beta|x64.ActiveCfg = Beta|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Beta|x64.Build.0 = Beta|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Beta|x86.ActiveCfg = Beta|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Beta|x86.Build.0 = Beta|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Debug|x64.ActiveCfg = Debug|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Debug|x64.Build.0 = Debug|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Debug|x86.ActiveCfg = Debug|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Debug|x86.Build.0 = Debug|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Optimized|Any CPU.ActiveCfg = Optimized|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Optimized|Any CPU.Build.0 = Optimized|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Optimized|x64.ActiveCfg = Optimized|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Optimized|x64.Build.0 = Optimized|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Optimized|x86.ActiveCfg = Optimized|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Optimized|x86.Build.0 = Optimized|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Release|Any CPU.Build.0 = Release|Any CPU - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Release|x64.ActiveCfg = Release|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Release|x64.Build.0 = Release|x64 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Release|x86.ActiveCfg = Release|x86 - {7195BFA6-8095-4618-96F6-8577DB13FB43}.Release|x86.Build.0 = Release|x86 {49C7D6E4-54AC-40D8-865D-F242C0E1247C}.Beta|Any CPU.ActiveCfg = Release|Any CPU {49C7D6E4-54AC-40D8-865D-F242C0E1247C}.Beta|Any CPU.Build.0 = Release|Any CPU {49C7D6E4-54AC-40D8-865D-F242C0E1247C}.Beta|x64.ActiveCfg = Release|Any CPU @@ -158,9 +130,6 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7195BFA6-8095-4618-96F6-8577DB13FB43} = {E01A632C-E967-4695-AAA3-790FCBF94E5B} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1DA6DA93-E121-42B1-95E2-0DB532EE6C2C} EndGlobalSection diff --git a/EmoTracker/App.axaml b/EmoTracker/App.axaml new file mode 100644 index 0000000..1e33feb --- /dev/null +++ b/EmoTracker/App.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + avares://EmoTracker/Resources/Fonts#Font Awesome 5 Free Solid + avares://EmoTracker/Resources/Fonts#Font Awesome 5 Brands Regular + + + + diff --git a/EmoTracker/App.axaml.cs b/EmoTracker/App.axaml.cs new file mode 100644 index 0000000..667b820 --- /dev/null +++ b/EmoTracker/App.axaml.cs @@ -0,0 +1,78 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using EmoTracker.Core; +using EmoTracker.Services; +using EmoTracker.Services.Updates; +using Serilog; +using Serilog.Events; +using System; +using System.IO; + +namespace EmoTracker +{ + public partial class App : Avalonia.Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + Data.Core.Transactions.TransactionProcessor.SetTransactionProcessor( + new Data.Core.Transactions.Processors.LocalTransactionProcessorWithUndo()); + + Core.Services.Backends.LogService.SetServiceBackend(new Services.LogService()); + Core.Services.Backends.DispatchService.SetServiceBackend(new Services.DispatchService()); + + try + { + string logDirectory = Path.Combine(UserDirectory.Path, "logs"); + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .Enrich.FromLogContext() + .WriteTo.File(Path.Combine(logDirectory, "emotracker_log.txt"), + rollingInterval: RollingInterval.Day, + buffered: true, + flushToDiskInterval: TimeSpan.FromSeconds(5)) + .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information) + .WriteTo.DeveloperConsole() + .CreateLogger(); + } + catch (Exception) + { + } + + // Load application settings + Data.ApplicationSettings.CreateInstance(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose; + desktop.MainWindow = new MainWindow(); + UpdateService.Instance.StartBackgroundCheck(); + + desktop.Exit += (s, e) => + { + try + { + UI.Media.ImageReferenceService.Instance.Stop(); + UpdateService.Instance.Dispose(); + + if (e.ApplicationExitCode == 0) + Extensions.ExtensionManager.Instance.OnApplicationClosing(); + } + catch { } + finally + { + Log.CloseAndFlush(); + } + }; + } + + base.OnFrameworkInitializationCompleted(); + } + + } +} diff --git a/EmoTracker/App.xaml b/EmoTracker/App.xaml deleted file mode 100644 index fba1915..0000000 --- a/EmoTracker/App.xaml +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/EmoTracker/App.xaml.cs b/EmoTracker/App.xaml.cs deleted file mode 100644 index 6a7ce9b..0000000 --- a/EmoTracker/App.xaml.cs +++ /dev/null @@ -1,131 +0,0 @@ -using EmoTracker.Core; -using EmoTracker.Services; -using Serilog; -using Serilog.Events; -using System; -using System.IO; -using System.Net; -using System.Runtime.InteropServices; -using System.Windows; -using System.Windows.Media; - -namespace EmoTracker -{ - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application - { - protected override void OnStartup(StartupEventArgs e) - { - ConfigurePlatformDllPaths(); - - Data.Core.Transactions.TransactionProcessor.SetTransactionProcessor(new Data.Core.Transactions.Processors.LocalTransactionProcessorWithUndo()); - - Core.Services.Backends.LogService.SetServiceBackend(new Services.LogService()); - Core.Services.Backends.DispatchService.SetServiceBackend(new Services.DispatchService()); - - try - { - // Create log directory - string logDirectory = Path.Combine(UserDirectory.Path, "logs"); - - // Configure Serilog logging - { - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Verbose() - .Enrich.FromLogContext() - .WriteTo.File(Path.Combine(logDirectory, "emotracker_log.txt"), rollingInterval: RollingInterval.Day, buffered: true, flushToDiskInterval: TimeSpan.FromSeconds(5)) - .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information) - .WriteTo.DeveloperConsole() - .CreateLogger(); - } - } - catch (Exception) - { - } - - // Windows 7 requires the following in order to connect via https - OperatingSystem os = System.Environment.OSVersion; - if (os.Version.Major < 10) - { - ServicePointManager.Expect100Continue = true; - ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; - } - - foreach (FontFamily fontFamily in Fonts.GetFontFamilies(new Uri("pack://application:,,,/"), "./Resources/Fonts/")) - { - string name = System.IO.Path.GetFileName(fontFamily.ToString()); - name = name.Replace("#", ""); - name = name.Replace(" ", ""); - - this.Resources.Add(name, fontFamily); - - // Perform action. - } - - // Load application settings - Data.ApplicationSettings.CreateInstance(); - - MainWindow window = new EmoTracker.MainWindow(); - MainWindow = window; - window.Show(); - - base.OnStartup(e); - } - - #region -- Platform Redirection for Native Assemblies -- - - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern IntPtr LoadLibrary(string libname); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - static extern bool SetDllDirectory(string lpPathName); - - private void ConfigurePlatformDllPaths() - { - try - { - string processorAssemblyPath = "x64"; - if (!Environment.Is64BitProcess) - processorAssemblyPath = "x86"; - - string privateBinPath = Path.Combine(AppDomain.CurrentDomain.SetupInformation.ApplicationBase, string.Format("{0}\\", processorAssemblyPath)); - SetDllDirectory(privateBinPath); - } - catch - { - throw new InvalidOperationException("Failed to set platform DLL search directory."); - } - } - -#endregion - - protected override void OnExit(ExitEventArgs e) - { - try - { - // Shutdown extensions - if (e.ApplicationExitCode == 0) - Extensions.ExtensionManager.Instance.OnApplicationClosing(); - - if (Data.ApplicationSettings.Instance.EnableDiscordRichPresence) - { - try - { - DiscordRpc.ClearPresence(); - DiscordRpc.Shutdown(); - } - catch - { - } - } - } - finally - { - base.OnExit(e); - } - } - } -} diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 8c17ddf..17a8007 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -1,4 +1,5 @@ -using EmoTracker.Core; +#nullable enable annotations +using EmoTracker.Core; using EmoTracker.Data; using EmoTracker.Data.Core.Transactions; using EmoTracker.Data.Core.Transactions.Processors; @@ -9,6 +10,7 @@ using EmoTracker.Data.Scripting; using EmoTracker.Extensions; using EmoTracker.Notifications; +using EmoTracker.Services; using EmoTracker.UI; using EmoTracker.UI.Media; using Newtonsoft.Json.Linq; @@ -20,11 +22,6 @@ using System.ComponentModel; using System.IO; using System.Linq; -using System.Windows; -using System.Windows.Controls.Primitives; -using System.Windows.Data; -using System.Windows.Input; -using System.Windows.Threading; namespace EmoTracker { @@ -36,7 +33,6 @@ public class ApplicationModel : ObservableSingleton, ICodeProv public DelegateCommand ActivatePackCommand { get; private set; } public DelegateCommand ShowPackageManagerCommand { get; private set; } public DelegateCommand ExportPackageOverrideCommand { get; private set; } - public DelegateCommand CheckForUpdateCommand { get; private set; } public DelegateCommand ShowBroadcastViewCommand { get; private set; } public DelegateCommand ShowDeveloperConsoleCommand { get; private set; } @@ -62,11 +58,7 @@ public string MainWindowTitle { get { -#if BETA - string title = string.Format("EmoTracker BETA {0}", AppUpdate.Instance.CurrentVersion); -#else string title = string.Format("EmoTracker {0}", ApplicationVersion.Current); -#endif if (Tracker.Instance.ActiveGamePackage != null && Tracker.Instance.ActiveGamePackageVariant != null) { @@ -130,14 +122,11 @@ public ApplicationModel() { InitializeNotifications(); - if (Application.Current is App) - { // Force initialize core managers PackageManager.CreateInstance(); PackageManager.Instance.Initialize(); InitializePackageManagerViews(); - } Tracker.Instance.OnPackageLoadStarting += Tracker_OnPackageLoadStarting; Tracker.Instance.OnPackageLoadComplete += Tracker_OnPackageLoadComplete; @@ -148,8 +137,6 @@ public ApplicationModel() ActivatePackCommand = new DelegateCommand(ActivatePackHandler); ShowPackageManagerCommand = new DelegateCommand(ShowPackManagerHandler); ExportPackageOverrideCommand = new DelegateCommand(ExportPackageOverrideHandler); - CheckForUpdateCommand = new DelegateCommand(CheckForUpdateHandler); - SaveCommand = new DelegateCommand(SaveHandler, CanSave); SaveAsCommand = new DelegateCommand(SaveAsHandler, CanSave); OpenCommand = new DelegateCommand(OpenHandler); @@ -163,10 +150,48 @@ public ApplicationModel() InstallPackageCommand = new DelegateCommand(InstallPackage); UninstallPackageCommand = new DelegateCommand(UninstallPackage, CanUninstallPackage); + // When HTTP game images finish downloading, refresh the package list once. + // Multiple images often load near-simultaneously, so we coalesce the refreshes: + // the first completion schedules a single Background-priority update; subsequent + // completions that arrive before it runs are folded into that one refresh. + EmoTracker.UI.Media.Utility.IconUtility.HttpImageLoaded += OnHttpImageLoaded; + } + + private bool _httpRefreshScheduled = false; + + private void OnHttpImageLoaded(object? sender, EventArgs e) + { + // Coalesce multiple near-simultaneous completions into one Background-priority refresh. + if (_httpRefreshScheduled) return; + _httpRefreshScheduled = true; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _httpRefreshScheduled = false; + + // Resolve game banner images into ResolvedImage so that + // bindings ({Binding Game.Image.ResolvedImage}) update. + // HTTP images bypass the ImageReferenceService pipeline + // (they download asynchronously into IconUtility.sHttpCache), + // so we bridge the two systems here. + foreach (var game in PackageManager.Instance.AvailableGames) + { + if (game.Image != null && game.Image.ResolvedImage == null) + { + var resolved = EmoTracker.UI.Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + if (resolved != null) + game.Image.ResolvedImage = resolved; + } + } + }, Avalonia.Threading.DispatcherPriority.Background); } public void Initialize() { + // Start the image resolution service. When --no-async-images is + // set, resolution falls back to synchronous on-demand behaviour. + ImageReferenceService.Instance.SyncMode = Data.ApplicationSettings.Instance.NoAsyncImages; + ImageReferenceService.Instance.Start(); + // Load and start extensions Extensions.ExtensionManager.CreateInstance(); Extensions.ExtensionManager.Instance.Start(); @@ -187,19 +212,33 @@ public void Initialize() private void ShowBroadcastView(object obj) { - MainWindow appWindow = Application.Current.MainWindow as MainWindow; - if (appWindow != null) - appWindow.ShowBroadcastView(); + if (mBroadcastView == null) + { + mBroadcastView = new BroadcastView(); + mBroadcastView.Closing += (_, _) => mBroadcastView = null; + + // Show without an owner so the broadcast view is an independent + // top-level window. Passing the main window as owner causes the + // OS to force the broadcast view above the main window at all times. + mBroadcastView.Show(); + } + else + { + mBroadcastView.Activate(); + } } + public BroadcastView BroadcastView => mBroadcastView; + private BroadcastView mBroadcastView; + private void ShowDevleoperConsole(object obj) { - MainWindow appWindow = Application.Current.MainWindow as MainWindow; - if (appWindow != null) - appWindow.ShowDeveloperConsole(); + var mainWindow = (Avalonia.Application.Current?.ApplicationLifetime + as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow; + mainWindow?.ShowDeveloperConsole(); } - private void InstallPackage(object obj) + private async void InstallPackage(object obj) { var package = (PackageRepositoryEntry)obj; @@ -211,17 +250,17 @@ private void InstallPackage(object obj) { string msg = $"You have user overrides in place for {package.Name} which may cause issues after updating. Do you want to backup and disable your overrides prior to updating?"; string caption = "Uninstall Package"; - MessageBoxResult res = MessageBox.Show(msg, caption, MessageBoxButton.YesNoCancel, MessageBoxImage.Exclamation); + bool? res = await DialogService.Instance.ShowYesNoCancelAsync(caption, msg); switch (res) { - case MessageBoxResult.Cancel: + case null: return; - case MessageBoxResult.No: + case false: break; - case MessageBoxResult.Yes: + case true: BackupOverrideResult bores = package.BackupOverride(); switch (bores) @@ -229,7 +268,7 @@ private void InstallPackage(object obj) case BackupOverrideResult.Failed: msg = $"Unable to backup {package.Name} overrides. Check to make sure that no other application is using the folder or you do not have a backup instance already. Canceling update"; caption = "Backup Failed"; - MessageBox.Show(msg, caption, MessageBoxButton.OK, MessageBoxImage.Error); + await DialogService.Instance.ShowOKAsync(caption, msg); return; case BackupOverrideResult.Success: @@ -244,15 +283,15 @@ private void InstallPackage(object obj) package.Install(); } - private void UninstallPackage(object obj) + private async void UninstallPackage(object obj) { var package = (PackageRepositoryEntry)obj; string msg = $"You are about to uninstall \"{package.Name}\". This will remove all the files associated with the package as well as the overrides. Do you wish to continue?"; string caption = "Uninstall Package"; - MessageBoxResult res = MessageBox.Show(msg, caption, MessageBoxButton.YesNo, MessageBoxImage.Exclamation); + bool res = await DialogService.Instance.ShowYesNoAsync(caption, msg); - if(res == MessageBoxResult.No) { return; } + if(!res) { return; } UninstallResult ures = package.Uninstall(); switch(ures) @@ -262,12 +301,12 @@ private void UninstallPackage(object obj) case UninstallResult.FailedUninstall: msg = $"Failed to uninstall \"{package.Name}\"! Please ensure no other applications are using the file and try again."; caption = "Failed to Uninstall"; - MessageBox.Show(msg, caption, MessageBoxButton.OK, MessageBoxImage.Error); + await DialogService.Instance.ShowOKAsync(caption, msg); break; case UninstallResult.FailedOverrides: msg = $"Failed to remove \"{package.Name}\" overrides folder. You will need to remove it manually"; caption = "Failed to Remove Overrides"; - MessageBox.Show(msg, caption, MessageBoxButton.OK, MessageBoxImage.Error); + await DialogService.Instance.ShowOKAsync(caption, msg); break; } } @@ -292,7 +331,7 @@ private void OpenPackOverrideFolderHandler(object obj) catch { }; if (Directory.Exists(Tracker.Instance.ActiveGamePackage.OverridePath)) - System.Diagnostics.Process.Start("explorer.exe", Tracker.Instance.ActiveGamePackage.OverridePath); + WindowService.Instance.OpenFolder(Tracker.Instance.ActiveGamePackage.OverridePath); else PushMarkdownNotification(NotificationType.Error, string.Format( @"### Cannot open override folder @@ -319,52 +358,45 @@ private void ExportPackageOverrideHandler(object obj) } else { - OverrideExportDialog dialog = new OverrideExportDialog() - { - Owner = Application.Current.MainWindow - }; - dialog.ShowDialog(); + OverrideExportDialog dialog = new OverrideExportDialog(); + _ = dialog.ShowDialog( + (Avalonia.Application.Current?.ApplicationLifetime as + Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); } } } private void ShowPackManagerHandler(object obj) { - UI.PackageManagerWindow window = new UI.PackageManagerWindow() { Owner = Application.Current.MainWindow }; - window.ShowDialog(); - - Keyboard.Focus(Application.Current.MainWindow); - } - - private void CheckForUpdateHandler(object obj) - { - UI.AppUpdateWindow window = new UI.AppUpdateWindow(false) { Owner = Application.Current.MainWindow }; - window.ShowDialog(); + UI.PackageManagerWindow window = new UI.PackageManagerWindow(); + _ = window.ShowDialog( + (Avalonia.Application.Current?.ApplicationLifetime as + Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } - private void RefreshHandler(object param) + private async void RefreshHandler(object param) { if (ApplicationSettings.Instance.PromptOnRefreshClose) { - MessageBoxResult result = MessageBox.Show(Application.Current.MainWindow, "Refreshing will cause you to lose all unsaved progress. Are you sure you want to refresh?", "Warning!", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); - if (result != MessageBoxResult.Yes) + bool result = await DialogService.Instance.ShowYesNoAsync("Warning!", "Refreshing will cause you to lose all unsaved progress. Are you sure you want to refresh?", defaultYes: false); + if (!result) return; } Reload(); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } - private void ResetUserDataHandler(object param) + private async void ResetUserDataHandler(object param) { if (Tracker.Instance.ActiveGamePackage != null) { if (ApplicationSettings.Instance.PromptOnRefreshClose) { - MessageBoxResult result = MessageBox.Show("Clearing overrides will cause you to lose all unsaved progress. Are you sure you want to continue?", "Warning!", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); - if (result != MessageBoxResult.Yes) + bool result = await DialogService.Instance.ShowYesNoAsync("Warning!", "Clearing overrides will cause you to lose all unsaved progress. Are you sure you want to continue?", defaultYes: false); + if (!result) return; } @@ -372,12 +404,12 @@ private void ResetUserDataHandler(object param) Reload(); } - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } private void ActivatePackHandler(object obj) { - Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + Core.Services.Dispatch.BeginInvoke(() => { IGamePackage package = obj as IGamePackage; IGamePackageVariant variant = obj as IGamePackageVariant; @@ -386,12 +418,12 @@ private void ActivatePackHandler(object obj) { Tracker.Instance.ActiveGamePackageVariant = null; Tracker.Instance.ActiveGamePackage = package; - } + } else if (variant != null) { Tracker.Instance.ActiveGamePackageVariant = variant; } - })); + }); } #region -- Visual Adjustments -- @@ -429,32 +461,28 @@ public void ResetLayoutScale(object obj = null) string mCurrentSavePath; - private void OpenHandler(object obj) + private async void OpenHandler(object obj) { string defaultSaveDataPath = Path.Combine(UserDirectory.Path, "saves"); - Microsoft.Win32.OpenFileDialog dialog = new Microsoft.Win32.OpenFileDialog(); - dialog.Filter = "EmoTracker Save File (*.json)|*.json"; - dialog.InitialDirectory = defaultSaveDataPath; - - if (dialog.ShowDialog() == true) + string filename = await DialogService.Instance.OpenFileAsync("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); + if (filename != null) { - if (!LoadProgress(dialog.FileName)) + if (!LoadProgress(filename)) { Reload(); - MessageBox.Show(Application.Current.MainWindow, + await DialogService.Instance.ShowOKAsync("Failed to load save data...", "Failed to load the requested save file. Possible reasons include:\n\n" + "• The original pack or variant no longer exists\n" + "• The save data has been corruped\n" + "• The pack version is different from the version used to save\n" + "• The pack contents do not match the save data.\n\n" + - "Note that certain types of user overrides can affect this, if added/changed since saving.", - "Failed to load save data...", MessageBoxButton.OK, MessageBoxImage.Error ); + "Note that certain types of user overrides can affect this, if added/changed since saving."); } else { - mCurrentSavePath = dialog.FileName; + mCurrentSavePath = filename; } } } @@ -476,7 +504,7 @@ private void SaveHandler(object obj) } } - private void SaveAsHandler(object obj) + private async void SaveAsHandler(object obj) { string defaultSaveDataPath = Path.Combine(UserDirectory.Path, "saves"); @@ -495,16 +523,11 @@ private void SaveAsHandler(object obj) ); } - Microsoft.Win32.SaveFileDialog dialog = new Microsoft.Win32.SaveFileDialog(); - dialog.AddExtension = true; - dialog.CheckPathExists = true; - dialog.Filter = "EmoTracker Save File (*.json)|*.json"; - dialog.InitialDirectory = defaultSaveDataPath; - - if (dialog.ShowDialog() == true) + string filename = await DialogService.Instance.SaveFileAsync("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); + if (filename != null) { - Directory.CreateDirectory(Path.GetDirectoryName(dialog.FileName)); - SaveProgress(dialog.FileName); + Directory.CreateDirectory(Path.GetDirectoryName(filename)); + SaveProgress(filename); } } @@ -517,8 +540,8 @@ private bool SaveProgress(string path) { bool bResult = Tracker.Instance.SaveProgress(path, (JObject root) => { - root["main_window_width"] = Application.Current.MainWindow.Width; - root["main_window_height"] = Application.Current.MainWindow.Height; + root["main_window_width"] = WindowService.Instance.MainWindowWidth; + root["main_window_height"] = WindowService.Instance.MainWindowHeight; JObject extensionData = new JObject(); bool bAddedAny = false; @@ -567,8 +590,8 @@ private bool LoadProgress(string path) { if (Tracker.Instance.LoadProgress(path, (JObject root) => { - Application.Current.MainWindow.Width = root.GetValue("main_window_width", Application.Current.MainWindow.Width); - Application.Current.MainWindow.Height = root.GetValue("main_window_height", Application.Current.MainWindow.Height); + WindowService.Instance.MainWindowWidth = root.GetValue("main_window_width", WindowService.Instance.MainWindowWidth); + WindowService.Instance.MainWindowHeight = root.GetValue("main_window_height", WindowService.Instance.MainWindowHeight); JObject extensionData = root.GetValue("extensions"); if (extensionData != null) @@ -612,7 +635,7 @@ private void OpenPackageDocumentation(object obj = null) { PackageRepositoryEntry entry = PackageManager.Instance.FindRepositoryEntry(Tracker.Instance.ActiveGamePackage.UniqueID); if (entry != null && !string.IsNullOrWhiteSpace(entry.DocumentationURL)) - System.Diagnostics.Process.Start(entry.DocumentationURL); + WindowService.Instance.OpenUrl(entry.DocumentationURL); } } @@ -693,7 +716,7 @@ private void Tracker_OnPackageLoadComplete(object sender, EventArgs e) OpenPackageDocumentationCommand.RaiseCanExecuteChanged(); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } public void AcquireLayouts() { @@ -762,7 +785,9 @@ public AvailablePackageViewFilterType AvailablePackageViewFilter set { if (SetProperty(ref mAvailablePackagesViewFilter, value)) - AvailablePackagesView.Refresh(); + { + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); + } } } @@ -787,16 +812,157 @@ public void SetAvailablePackageViewFilter(object param) } } - ListCollectionView mAvailablePackagesView; - ListCollectionView mInstalledPackagesView; + /// + /// Groups available packages by game name for display in the Avalonia package manager. + /// Each entry has a Name (game name) and Items (packages in that group). + /// Uses the same sorting logic as WPF's RepoEntryGameNameSort and resolves + /// game display names via PackageManager.FindGame. + /// + public IEnumerable AvailablePackagesGroupedView + { + get + { + var entries = (PackageManager.Instance.AvailablePackages ?? Enumerable.Empty()) + .Where(e => PackageFilter(e)) + .ToList(); + + // Sort using the same logic as WPF's RepoEntryGameNameSort + entries.Sort((a, b) => + { + var x = PackageManager.Instance.FindGame(a.Game); + var y = PackageManager.Instance.FindGame(b.Game); + + if (x != null && x.Key.Equals("Other", StringComparison.OrdinalIgnoreCase)) + return 1; + if (y != null && y.Key.Equals("Other", StringComparison.OrdinalIgnoreCase)) + return -1; + + int result; + + result = (x?.SeriesPriority ?? 0).CompareTo(y?.SeriesPriority ?? 0); + if (result != 0) return result; + + result = CompareStringOrdinal(x?.Series, y?.Series); + if (result != 0) return result; + + result = (x?.Priority ?? 0).CompareTo(y?.Priority ?? 0); + if (result != 0) return result; + + result = CompareStringOrdinal(x?.Name, y?.Name); + if (result != 0) return result; + + result = ComparePreferBool( + a.Flags.HasFlag(PackageFlags.Official), + b.Flags.HasFlag(PackageFlags.Official)); + if (result != 0) return result; + + result = ComparePreferBool( + a.Flags.HasFlag(PackageFlags.Featured), + b.Flags.HasFlag(PackageFlags.Featured)); + if (result != 0) return result; + + return CompareStringOrdinal(a.Author, b.Author); + }); + + // Group by resolved game display name (matches WPF's GroupDescription + // which uses GameNameToActualGameNameConverter) + return entries + .GroupBy(e => + { + var game = PackageManager.Instance.FindGame(e.Game); + return game?.Name ?? e.Game; + }) + .Select(g => + { + var game = PackageManager.Instance.FindGame(g.Key); + return new PackageGroup(g.Key, g, game); + }); + } + } + + private static int CompareStringOrdinal(string x, string y) + { + if (!string.IsNullOrWhiteSpace(x) && string.IsNullOrWhiteSpace(y)) return -1; + if (string.IsNullOrWhiteSpace(x) && !string.IsNullOrWhiteSpace(y)) return 1; + return string.CompareOrdinal(x, y); + } + + private static int ComparePreferBool(bool x, bool y) + { + if (x && !y) return -1; + if (!x && y) return 1; + return 0; + } + + public IEnumerable InstalledPackagesView => + PackageManager.Instance.InstalledPackages ?? Enumerable.Empty(); + + /// + /// Groups installed packages by game name for display in the Avalonia settings menu. + /// + public IEnumerable InstalledPackagesGroupedView + { + get + { + var packages = (PackageManager.Instance.InstalledPackages ?? Enumerable.Empty()).ToList(); + + packages.Sort((a, b) => + { + var x = PackageManager.Instance.FindGame(a.Game); + var y = PackageManager.Instance.FindGame(b.Game); + + if (x != null && x.Key.Equals("Other", StringComparison.OrdinalIgnoreCase)) + return 1; + if (y != null && y.Key.Equals("Other", StringComparison.OrdinalIgnoreCase)) + return -1; + + int result = CompareStringOrdinal(x?.Series, y?.Series); + if (result != 0) return result; + + result = (x?.SeriesPriority ?? 0).CompareTo(y?.SeriesPriority ?? 0); + if (result != 0) return result; + + result = (x?.Priority ?? 0).CompareTo(y?.Priority ?? 0); + if (result != 0) return result; + + result = CompareStringOrdinal(x?.Name, y?.Name); + if (result != 0) return result; - public CollectionView AvailablePackagesView + return CompareStringOrdinal(a.Author, b.Author); + }); + + return packages + .GroupBy(p => + { + var game = PackageManager.Instance.FindGame(p.Game); + return game?.Name ?? p.Game; + }) + .Select(g => new InstalledPackageGroup(g.Key, g)); + } + } + + public class InstalledPackageGroup { - get { return mAvailablePackagesView; } + public string Name { get; } + public IEnumerable Items { get; } + public InstalledPackageGroup(string name, IEnumerable items) + { + Name = name; + Items = items; + } } - public CollectionView InstalledPackagesView + + public class PackageGroup { - get { return mInstalledPackagesView; } + public string Name { get; } + public IEnumerable Items { get; } + public PackageManager.Game Game { get; } + public PackageGroup(string name, IEnumerable items, PackageManager.Game game = null) + { + Name = name; + Items = items; + Game = game; + } } void InitializePackageManagerViews() @@ -805,22 +971,11 @@ void InitializePackageManagerViews() PackageManager.Instance.OnGameListDownloaded += PackageManager_OnGameListDownloaded; - mAvailablePackagesView = new ListCollectionView(PackageManager.Instance.AvailablePackages as IList); - mAvailablePackagesView.CustomSort = new RepoEntryGameNameSort(); - mAvailablePackagesView.Filter = new Predicate(PackageFilter); - mAvailablePackagesView.GroupDescriptions.Add(new PropertyGroupDescription("Game", UI.Converters.GameNameToActualGameNameConverter.Instance)); - - mInstalledPackagesView = new ListCollectionView(PackageManager.Instance.InstalledPackages as IList); - mInstalledPackagesView.CustomSort = new GameNameSort(); - mInstalledPackagesView.GroupDescriptions.Add(new PropertyGroupDescription("Game", UI.Converters.GameNameToActualGameNameConverter.Instance)); - - AvailablePackagesView.Refresh(); - InstalledPackagesView.Refresh(); // Configure auto-refresh for the package manager - DispatcherTimer timer = new DispatcherTimer(); - timer.Interval = TimeSpan.FromMinutes(30); - timer.Tick += OnRefreshPackageRepositoriesTimer; + System.Timers.Timer timer = new System.Timers.Timer(TimeSpan.FromMinutes(30).TotalMilliseconds); + timer.Elapsed += (s, e) => OnRefreshPackageRepositoriesTimer(s, e); + timer.AutoReset = true; timer.Start(); PackageManager.Instance.OnRepositoryUpdated += PackageManager_OnRepositoryUpdated; @@ -838,8 +993,8 @@ private void OnRefreshPackageRepositoriesTimer(object sender, EventArgs e) private void PackageManager_OnGameListDownloaded(object sender, EventArgs e) { - AvailablePackagesView.Refresh(); - InstalledPackagesView.Refresh(); + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); + NotifyPropertyChanged(nameof(InstalledPackagesView)); } private string mPackFilterText; @@ -857,7 +1012,7 @@ public string PackFilterText private void RefreshPackageCollectionView() { - mAvailablePackagesView.Refresh(); + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); } private bool PackageFilter(object obj) @@ -1079,12 +1234,15 @@ public bool HasPendingNotifications get { return mNotifications.Count > 0; } } - DispatcherTimer mNotificationUpdateTimer; + System.Timers.Timer mNotificationUpdateTimer; void InitializeNotifications() { - mNotificationUpdateTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal, NotificationExpirationTimer_Tick, Application.Current.Dispatcher); + mNotificationUpdateTimer = new System.Timers.Timer(500); + mNotificationUpdateTimer.Elapsed += (s, e) => Core.Services.Dispatch.BeginInvoke(() => NotificationExpirationTimer_Tick(s, e)); + mNotificationUpdateTimer.AutoReset = true; + mNotificationUpdateTimer.Start(); mNotifications.CollectionChanged += Notifications_CollectionChanged; ScriptManager.Instance.SetNotificationService(this); @@ -1105,22 +1263,6 @@ private void NotificationExpirationTimer_Tick(object sender, EventArgs e) foreach (Notification n in mNotifications) { - FrameworkElement container = null; - { - MainWindow appWindow = Application.Current.MainWindow as MainWindow; - if (appWindow != null) - { - // This is bit lame, but WPF has some unfortunate limitations with respect to - // handling completed events for animations triggered from DataTriggers - container = appWindow.NotificationsHost.ItemContainerGenerator.ContainerFromItem(n) as FrameworkElement; - } - } - - if (container != null && container.IsMouseOver) - { - n.ExpirationTime = now; - continue; - } if (n.ExpirationTime <= now || n.Expired) { @@ -1130,11 +1272,6 @@ private void NotificationExpirationTimer_Tick(object sender, EventArgs e) { toRemove.Add(n); } - else if (container != null) - { - if (container.RenderSize.Height == 0) - toRemove.Add(n); - } } } @@ -1155,7 +1292,7 @@ public void PushMarkdownNotification(NotificationType type, string markdown, int { // Use the dispatcher here to make sure we're not eating up expiry time during long blocking operations // this call may be nested within. - Application.Current.Dispatcher.BeginInvoke(new Action(() => + Core.Services.Dispatch.BeginInvoke(() => { MarkdownNotification notification = new MarkdownNotification(timeout) { @@ -1170,7 +1307,7 @@ public void PushMarkdownNotification(NotificationType type, string markdown, int mPreviousNotifications.Insert(0, notification); mNotifications.Insert(0, notification); - })); + }); } } diff --git a/EmoTracker/EmoTracker.csproj b/EmoTracker/EmoTracker.csproj index 27f9e61..0545070 100644 --- a/EmoTracker/EmoTracker.csproj +++ b/EmoTracker/EmoTracker.csproj @@ -5,75 +5,73 @@ WinExe EmoTracker EmoTracker - net472 - true + net8.0 emohead_icon_transparent_7h3_icon.ico - app.manifest false - true - - - + + + - + + - - - - + + + + + - - ..\External\ConnectorLib\ConnectorLib.dll - False - - - ..\External\ConnectorLib\ConnectorLib.sd2snes.dll - False - - - - + + + + - + + + + + + + + + + + + + + + + + - + + - - + + + + - - - - - - - - - - - - - - - - - + + + + diff --git a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs index 84d373c..566670e 100644 --- a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs +++ b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs @@ -1,6 +1,7 @@ -using ConnectorLib; using EmoTracker.Core; +using EmoTracker.Core.Services; using EmoTracker.Data; +using EmoTracker.Data.AutoTracking; using EmoTracker.Data.Packages; using EmoTracker.Data.Scripting; using Newtonsoft.Json.Linq; @@ -9,8 +10,6 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Threading.Tasks; -using System.Windows; -using System.Windows.Threading; namespace EmoTracker.Extensions.AutoTracker { @@ -24,11 +23,11 @@ public class AutoTrackerExtension : ObservableObject, Extension, IMemoryWatchSer public int Priority { get { return -100; } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get { - return new AutoTrackerExtensionView() { DataContext = this }; + return new AutoTrackerExtensionView { DataContext = this }; } } @@ -49,7 +48,7 @@ public void OnPackageLoaded() public bool Active { - get { return mApplicableConnectorTypes.Count > 0 && mActiveMemoryUpdates.Count > 0; } + get { return mApplicableProviders.Count > 0 && mActiveMemoryUpdates.Count > 0; } } bool mbError = false; @@ -61,7 +60,7 @@ public bool Error #endregion - #region -- Connector Management -- + #region -- Provider Management -- bool mbConnected = false; public bool Connected @@ -78,122 +77,81 @@ private set { if (SetProperty(ref mActivePlatform, value)) { - mApplicableConnectorTypes.Clear(); + mApplicableProviders.Clear(); - ConnectorType connectorType; - if (ConnectorTypeForGamePlatform(mActivePlatform, out connectorType)) + if (Tracker.Instance.ActiveGamePackage != null) { - var availableConnectorInstanceTypes = ConnectorFactory.Available[(int)connectorType]; - if (availableConnectorInstanceTypes != null && availableConnectorInstanceTypes.Length > 0) + var providers = AutoTrackingProviderRegistry.Instance.GetProvidersForPack(Tracker.Instance.ActiveGamePackage); + foreach (var provider in providers) { - foreach (var availableType in availableConnectorInstanceTypes) - { - if (availableType.Visibility != ConnectorFactory.Visibility.Production) - continue; - - mApplicableConnectorTypes.Add(new ConnectorTypeDesc() - { - Name = availableType.Name, - InstanceType = availableType.Type - }); - } + mApplicableProviders.Add(provider); } } } } } - public class ConnectorTypeDesc : ObservableObject + ObservableCollection mApplicableProviders = new ObservableCollection(); + public IEnumerable ApplicableProviders { - public string Name { get; set; } - public Type InstanceType { get; set; } - - bool mbActive = false; - public bool Active - { - get { return mbActive; } - set { SetProperty(ref mbActive, value); } - } + get { return mApplicableProviders; } } - ObservableCollection mApplicableConnectorTypes = new ObservableCollection(); - public IEnumerable ApplicableConnectorTypes + IAutoTrackingProvider mSelectedProvider; + public IAutoTrackingProvider SelectedProvider { - get { return mApplicableConnectorTypes; } - } - - private bool ConnectorTypeForGamePlatform(GamePlatform platform, out ConnectorType connectorType) - { - switch (platform) + get { return mSelectedProvider; } + private set { - case GamePlatform.NES: - connectorType = ConnectorType.NESConnector; - return true; - - case GamePlatform.SNES: - connectorType = ConnectorType.SNESConnector; - return true; - - case GamePlatform.N64: - connectorType = ConnectorType.N64Connector; - return true; - - case GamePlatform.Gameboy: - connectorType = ConnectorType.GBConnector; - return true; + var prev = mSelectedProvider; + if (SetProperty(ref mSelectedProvider, value)) + { + if (prev != null) + prev.AvailableDevicesChanged -= SelectedProvider_AvailableDevicesChanged; - case GamePlatform.GBA: - connectorType = ConnectorType.GBAConnector; - return true; + if (mSelectedProvider != null) + mSelectedProvider.AvailableDevicesChanged += SelectedProvider_AvailableDevicesChanged; - case GamePlatform.Genesis: - connectorType = ConnectorType.GenesisConnector; - return true; + InvalidateCommandAvailability(); + } } - - connectorType = ConnectorType.ExternalConnector; - return false; } - ConnectorTypeDesc mSelectedConnectorType; - public ConnectorTypeDesc SelectedConnectorType + private void SelectedProvider_AvailableDevicesChanged(object sender, EventArgs e) { - get { return mSelectedConnectorType; } - private set - { - if (SetProperty(ref mSelectedConnectorType, value)) - { - ActiveConnector = null; - UpdateConnectorTypeDescActiveState(mSelectedConnectorType); - InvalidateCommandAvailability(); + // Auto-select first device if the previously selected one is gone or none was chosen + if (SelectedProvider != null && SelectedProvider.DefaultDevice == null && SelectedProvider.AvailableDevices.Count > 0) + SelectedProvider.DefaultDevice = SelectedProvider.AvailableDevices[0]; - if (CanStartAutoTracking()) - StartAutoTracking(); - } - } + Dispatch.BeginInvoke(() => + { + InvalidateCommandAvailability(); + NotifyPropertyChanged(nameof(SelectedProvider)); + }); } - IAddressableConnector mActiveConnector; - public IAddressableConnector ActiveConnector + IAutoTrackingProvider mActiveProvider; + public IAutoTrackingProvider ActiveProvider { - get { return mActiveConnector; } + get { return mActiveProvider; } private set { Connected = false; WaitForPendingMemoryUpdate(); - IAddressableConnector prev = mActiveConnector; - if (SetProperty(ref mActiveConnector, value)) + IAutoTrackingProvider prev = mActiveProvider; + if (SetProperty(ref mActiveProvider, value)) { - IGameConnector prevGC = prev as IGameConnector; - if (prevGC != null) - prevGC.Dispose(); + if (prev != null) + { + prev.ConnectionStatusChanged -= ActiveProvider_ConnectionStatusChanged; + prev.DisconnectAsync().GetAwaiter().GetResult(); + } - IGameConnector gc = mActiveConnector as IGameConnector; - if (gc != null) + if (mActiveProvider != null) { - Connected = gc.Connected; - gc.ConnectionStatusChanged += ActiveConnector_ConnectionStatusChanged; ; + Connected = mActiveProvider.IsConnected; + mActiveProvider.ConnectionStatusChanged += ActiveProvider_ConnectionStatusChanged; } InvalidateCommandAvailability(); @@ -201,23 +159,13 @@ private set } } - private void ActiveConnector_ConnectionStatusChanged(object sender, (ConnectionStatus status, string) e) - { - IGameConnector gc = mActiveConnector as IGameConnector; - if (gc != null) - { - Connected = gc.Connected || e.status == ConnectionStatus.Open; - } - } + public IAutoTrackingProvider ActiveConnector => ActiveProvider; - void UpdateConnectorTypeDescActiveState(ConnectorTypeDesc desc) + private void ActiveProvider_ConnectionStatusChanged(object sender, bool connected) { - foreach (ConnectorTypeDesc entry in ApplicableConnectorTypes) + if (mActiveProvider != null) { - if (object.ReferenceEquals(entry, desc)) - entry.Active = true; - else - entry.Active = false; + Connected = connected; } } @@ -229,28 +177,11 @@ public byte ReadU8(ulong address, byte defaultVal = 0) { try { -#if false - PackageManager.Game game = null; - if (Tracker.Instance.ActiveGamePackage != null) + if (ActiveProvider != null && Connected) { - PackageManager.Game gameInstance = PackageManager.Instance.FindGame(Tracker.Instance.ActiveGamePackage.Game); - if (gameInstance != PackageManager.Instance.DefaultGame) - game = gameInstance; - } - - if (game == null || !game.IsMemoryRangeAccessAllowed(address, address)) - throw new InvalidOperationException("The requested memory address(es) are not allowed to be read"); -#endif - - if (ActiveConnector != null && Connected) - { - I8BitConnector as8Bit = ActiveConnector as I8BitConnector; - if (as8Bit != null) - { - byte val = defaultVal; - if (as8Bit.Read8(address, out val)) - return val; - } + byte val = defaultVal; + if (ActiveProvider.Read8(address, out val)) + return val; } } catch (Exception e) @@ -271,28 +202,11 @@ public ushort ReadU16(ulong address, ushort defaultVal = 0) { try { -#if false - PackageManager.Game game = null; - if (Tracker.Instance.ActiveGamePackage != null) - { - PackageManager.Game gameInstance = PackageManager.Instance.FindGame(Tracker.Instance.ActiveGamePackage.Game); - if (gameInstance != PackageManager.Instance.DefaultGame) - game = gameInstance; - } - - if (game == null || !game.IsMemoryRangeAccessAllowed(address, address + 1)) - throw new InvalidOperationException("The requested memory address(es) are not allowed to be read"); -#endif - - if (ActiveConnector != null && Connected) + if (ActiveProvider != null && Connected) { - I16BitConnector as16Bit = ActiveConnector as I16BitConnector; - if (as16Bit != null) - { - ushort val = defaultVal; - if (as16Bit.Read16(address, out val)) - return val; - } + ushort val = defaultVal; + if (ActiveProvider.Read16(address, out val)) + return val; } } catch (Exception e) @@ -309,13 +223,14 @@ public short Read16(ulong address, short defaultVal = 0) return unchecked((short)ReadU16(address, unchecked((ushort)defaultVal))); } -#endregion + #endregion -#region -- Commands -- + #region -- Commands -- DelegateCommand mStartCommand; DelegateCommand mStopCommand; - DelegateCommand mSetConnectorTypeCommand; + DelegateCommand mSetProviderCommand; + DelegateCommand mSetDeviceCommand; public DelegateCommand StartCommand { @@ -329,10 +244,16 @@ public DelegateCommand StopCommand set { SetProperty(ref mStopCommand, value); } } - public DelegateCommand SetConnectorTypeCommand + public DelegateCommand SetProviderCommand + { + get { return mSetProviderCommand; } + set { SetProperty(ref mSetProviderCommand, value); } + } + + public DelegateCommand SetDeviceCommand { - get { return mSetConnectorTypeCommand; } - set { SetProperty(ref mSetConnectorTypeCommand, value); } + get { return mSetDeviceCommand; } + set { SetProperty(ref mSetDeviceCommand, value); } } void InvalidateCommandAvailability() @@ -341,22 +262,48 @@ void InvalidateCommandAvailability() StopCommand.RaiseCanExecuteChanged(); } - private void SetConnectorType(object obj) + private async void SetProvider(object obj) { - SelectedConnectorType = obj as ConnectorTypeDesc; + IAutoTrackingProvider provider = obj as IAutoTrackingProvider; + if (provider != null) + { + SelectedProvider = provider; + await provider.RefreshDevicesAsync(); + + // Auto-select first device if none selected + if (provider.DefaultDevice == null && provider.AvailableDevices.Count > 0) + { + provider.DefaultDevice = provider.AvailableDevices[0]; + } + + InvalidateCommandAvailability(); + NotifyPropertyChanged(nameof(SelectedProvider)); + } + } + + private void SetDevice(object obj) + { + IAutoTrackingDevice device = obj as IAutoTrackingDevice; + if (device != null && SelectedProvider != null) + { + SelectedProvider.DefaultDevice = device; + + if (CanStartAutoTracking()) + StartAutoTracking(); + } } private bool CanStopAutoTracking(object obj = null) { - return ActiveConnector != null; + return ActiveProvider != null; } private void StopAutoTracking(object obj = null) { WaitForPendingMemoryUpdate(); - bool bWasActive = ActiveConnector != null; - ActiveConnector = null; + bool bWasActive = ActiveProvider != null; + ActiveProvider = null; if (bWasActive) ScriptManager.Instance.InvokeStandardCallback(ScriptManager.StandardCallback.AutoTrackerStopped); @@ -364,14 +311,14 @@ private void StopAutoTracking(object obj = null) private bool CanStartAutoTracking(object obj = null) { - return ActiveConnector == null && SelectedConnectorType != null; + return ActiveProvider == null && SelectedProvider != null && SelectedProvider.DefaultDevice != null; } - private void StartAutoTracking(object obj = null) + private async void StartAutoTracking(object obj = null) { if (CanStartAutoTracking(obj)) { - if (SelectedConnectorType != null) + if (SelectedProvider != null) { // Force mark all memory updates as dirty to ensure they update foreach (IUpdateWithConnector update in mActiveMemoryUpdates) @@ -381,8 +328,8 @@ private void StartAutoTracking(object obj = null) try { - IAddressableConnector instance = Activator.CreateInstance(SelectedConnectorType.InstanceType) as IAddressableConnector; - ActiveConnector = instance; + await SelectedProvider.ConnectAsync(); + ActiveProvider = SelectedProvider; ScriptManager.Instance.InvokeStandardCallback(ScriptManager.StandardCallback.AutoTrackerStarted); } @@ -393,65 +340,26 @@ private void StartAutoTracking(object obj = null) } } -#endregion - - class ConnectorLibLogger // : ConnectorLib.Common.ILogger - { - public void Debug(string msg) - { - System.Diagnostics.Debug.Print(msg); - } - - public void Error(string msg) - { - System.Diagnostics.Debug.Print(msg); - } - - public void Exception(Exception e, string msg) - { - System.Diagnostics.Debug.Print(msg); - } - - public void Info(string msg) - { - System.Diagnostics.Debug.Print(msg); - } - - public void Message(string msg) - { - System.Diagnostics.Debug.Print(msg); - } - - public void Warning(string msg) - { - System.Diagnostics.Debug.Print(msg); - } - } + #endregion public AutoTrackerExtension() { - // Call this here to force an exception during load if we can't load the connectorlib DLL - StopAutoTracking(); - - // ConnectorLib.Common.Log.Logger = new ConnectorLibLogger(); - - sd2snesConnector.Usb2SnesApplicationName = string.Format("EmoTracker {0}", ApplicationVersion.Current); - StartCommand = new DelegateCommand(StartAutoTracking, CanStartAutoTracking); StopCommand = new DelegateCommand(StopAutoTracking, CanStopAutoTracking); - SetConnectorTypeCommand = new DelegateCommand(SetConnectorType); + SetProviderCommand = new DelegateCommand(SetProvider); + SetDeviceCommand = new DelegateCommand(SetDevice); } - DispatcherTimer mUpdateTimer; + System.Timers.Timer mUpdateTimer; public void Start() { ScriptManager.Instance.SetGlobalObject("AutoTracker", this); ScriptManager.Instance.SetMemoryWatchService(this); - mUpdateTimer = new System.Windows.Threading.DispatcherTimer(); - mUpdateTimer.Tick += new EventHandler(UpdateMemoryHooks); - mUpdateTimer.Interval = new TimeSpan(0, 0, 0, 0, 30); + mUpdateTimer = new System.Timers.Timer(30); + mUpdateTimer.Elapsed += (s, e) => UpdateMemoryHooks(s, e); + mUpdateTimer.AutoReset = true; mUpdateTimer.Start(); } @@ -479,7 +387,7 @@ private void UpdateMemoryHooks(object sender, EventArgs e) if (HasPendingMemoryUpdate()) return; - if (ActiveConnector != null && Connected) + if (ActiveProvider != null && Connected) { foreach (IUpdateWithConnector update in mActiveMemoryUpdates) { @@ -497,21 +405,12 @@ private void UpdateMemoryHooks(object sender, EventArgs e) game = gameInstance; } - var connectorInstance = ActiveConnector; + var providerInstance = ActiveProvider; - IGameConnector gameConnector = connectorInstance as IGameConnector; - - // ConnectorLib occasionally disconnects a Lua connector without properly updating - // the connection status via our callback. particularly when the script is shut down - // via the emulator without disconnecting. Detect that here. - if (gameConnector != null && !gameConnector.Connected) + // Detect disconnection — stop autotracking automatically + if (providerInstance != null && !providerInstance.IsConnected) { - // Mark all segments dirty to force a re-read when/if we come back from the error - foreach (IUpdateWithConnector update in mActiveMemoryUpdates) - { - update.MarkDirty(); - } - + Dispatch.BeginInvoke(() => StopAutoTracking()); Error = true; return; } @@ -533,7 +432,7 @@ private void UpdateMemoryHooks(object sender, EventArgs e) IUpdateWithConnector update = PopPendingMemoryUpdate(); if (update != null) { - if (update.UpdateWithConnector(connectorInstance, game) != MemoryUpdateResult.Success) + if (update.UpdateWithConnector(providerInstance, game) != MemoryUpdateResult.Success) bError = true; ++count; @@ -546,11 +445,11 @@ private void UpdateMemoryHooks(object sender, EventArgs e) } finally { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { Error = bError; mActiveUpdateTask = null; - })); + }); } }); } @@ -559,6 +458,13 @@ private void UpdateMemoryHooks(object sender, EventArgs e) public void Stop() { StopAutoTracking(); + + if (mUpdateTimer != null) + { + mUpdateTimer.Stop(); + mUpdateTimer.Dispose(); + mUpdateTimer = null; + } } public JToken SerializeToJson() @@ -598,7 +504,7 @@ public void RemoveMemoryWatch(IMemorySegment segmentBase) } } - public MemoryTimer AddMemoryTimer(string name, Func callback, int period) + public MemoryTimer AddMemoryTimer(string name, Func callback, int period) { lock (this) { diff --git a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.axaml b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.axaml new file mode 100644 index 0000000..4b6aeb8 --- /dev/null +++ b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.axaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.axaml.cs b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.axaml.cs new file mode 100644 index 0000000..7200b1b --- /dev/null +++ b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.axaml.cs @@ -0,0 +1,289 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using EmoTracker.Data.AutoTracking; +using EmoTracker.Data.Settings; +using System; +using System.ComponentModel; + +namespace EmoTracker.Extensions.AutoTracker +{ + public partial class AutoTrackerExtensionView : UserControl + { + public AutoTrackerExtensionView() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private AutoTrackerExtension _extension; + + // Pulse animation state + private DispatcherTimer _pulseTimer; + private double _pulsePhase = 0.0; + private static readonly Color PulseFrom = Color.Parse("#717171"); + private static readonly Color PulseTo = Color.Parse("#53A893"); + + private void OnDataContextChanged(object sender, EventArgs e) + { + if (_extension != null) + _extension.PropertyChanged -= Extension_PropertyChanged; + + _extension = DataContext as AutoTrackerExtension; + + if (_extension != null) + _extension.PropertyChanged += Extension_PropertyChanged; + + UpdateStatusColor(); + AttachContextMenuHandler(); + } + + private void AttachContextMenuHandler() + { + var grid = this.Content as Grid; + if (grid?.ContextMenu != null) + { + grid.ContextMenu.Opening -= ContextMenu_Opening; + grid.ContextMenu.Opening += ContextMenu_Opening; + } + } + + private void ContextMenu_Opening(object sender, CancelEventArgs e) + { + RebuildContextMenu(); + } + + private void Extension_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(AutoTrackerExtension.Connected): + case nameof(AutoTrackerExtension.Error): + case nameof(AutoTrackerExtension.ActiveProvider): + case nameof(AutoTrackerExtension.SelectedProvider): + UpdateStatusColor(); + break; + } + } + + private bool CanStartAutoTracking => + _extension != null && + _extension.ActiveProvider == null && + _extension.SelectedProvider != null && + _extension.SelectedProvider.DefaultDevice != null; + + private void StartPulse() + { + if (_pulseTimer != null) + return; + + _pulsePhase = 0.0; + _pulseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) }; + _pulseTimer.Tick += OnPulseTick; + _pulseTimer.Start(); + } + + private void StopPulse() + { + if (_pulseTimer == null) + return; + + _pulseTimer.Stop(); + _pulseTimer.Tick -= OnPulseTick; + _pulseTimer = null; + } + + private void OnPulseTick(object sender, EventArgs e) + { + _pulsePhase += 0.0125; // full grey→cyan→grey cycle in ~4 seconds at 50ms intervals + if (_pulsePhase >= 1.0) + _pulsePhase -= 1.0; + + double t = (Math.Sin(_pulsePhase * 2 * Math.PI) + 1.0) / 2.0; + + byte r = (byte)(PulseFrom.R + (PulseTo.R - PulseFrom.R) * t); + byte g = (byte)(PulseFrom.G + (PulseTo.G - PulseFrom.G) * t); + byte b = (byte)(PulseFrom.B + (PulseTo.B - PulseFrom.B) * t); + + if (this.FindControl("StatusIcon") is TextBlock icon) + icon.Foreground = new SolidColorBrush(new Color(255, r, g, b)); + } + + private void UpdateStatusColor() + { + if (this.FindControl("StatusIcon") is not TextBlock icon) + return; + + if (_extension == null) + { + StopPulse(); + icon.Foreground = SolidColorBrush.Parse("#717171"); + return; + } + + if (_extension.Error) + { + StopPulse(); + icon.Foreground = SolidColorBrush.Parse(ApplicationColors.Instance.Status_Generic_Error); + } + else if (!_extension.Connected && _extension.ActiveProvider != null) + { + StopPulse(); + icon.Foreground = SolidColorBrush.Parse(ApplicationColors.Instance.Status_Generic_Warning); + } + else if (_extension.ActiveProvider != null) + { + StopPulse(); + icon.Foreground = new SolidColorBrush(Color.Parse("#35e0b5")); + } + else if (CanStartAutoTracking) + { + StartPulse(); + } + else + { + StopPulse(); + icon.Foreground = SolidColorBrush.Parse("#717171"); + } + } + + private void RebuildContextMenu() + { + if (_extension == null) + return; + + var grid = this.Content as Grid; + if (grid?.ContextMenu == null) + return; + + var menu = grid.ContextMenu; + menu.Items.Clear(); + + // Start / Stop + menu.Items.Add(new MenuItem { Header = "Start", Command = _extension.StartCommand }); + menu.Items.Add(new MenuItem { Header = "Stop", Command = _extension.StopCommand }); + menu.Items.Add(new Separator()); + + // Provider submenu + var providerMenu = new MenuItem { Header = "Provider" }; + foreach (var provider in _extension.ApplicableProviders) + { + var isSelected = provider == _extension.SelectedProvider; + var providerItem = new MenuItem + { + Header = provider.DisplayName, + Icon = isSelected ? new TextBlock { Text = "\u2713" } : null + }; + var capturedProvider = provider; + providerItem.Click += (s, e) => + { + if (_extension.SetProviderCommand.CanExecute(capturedProvider)) + _extension.SetProviderCommand.Execute(capturedProvider); + }; + providerMenu.Items.Add(providerItem); + } + menu.Items.Add(providerMenu); + + // Device submenu (with connection status) + var deviceMenu = new MenuItem { Header = "Device" }; + if (_extension.SelectedProvider != null) + { + foreach (var device in _extension.SelectedProvider.AvailableDevices) + { + var isDefault = device == _extension.SelectedProvider.DefaultDevice; + string status = device.IsConnected ? " [Connected]" : ""; + var deviceItem = new MenuItem + { + Header = device.DisplayName + status, + Icon = isDefault ? new TextBlock { Text = "\u2713" } : null + }; + var capturedDevice = device; + deviceItem.Click += (s, e) => + { + if (_extension.SetDeviceCommand.CanExecute(capturedDevice)) + _extension.SetDeviceCommand.Execute(capturedDevice); + }; + deviceMenu.Items.Add(deviceItem); + } + } + menu.Items.Add(deviceMenu); + + // Provider options + if (_extension.SelectedProvider != null && _extension.SelectedProvider.Options.Count > 0) + { + menu.Items.Add(new Separator()); + + foreach (var option in _extension.SelectedProvider.Options) + { + AddOptionMenuItem(menu, option); + } + } + + // Device options (from default device) + if (_extension.SelectedProvider?.DefaultDevice != null && _extension.SelectedProvider.DefaultDevice.Options.Count > 0) + { + menu.Items.Add(new Separator()); + + foreach (var option in _extension.SelectedProvider.DefaultDevice.Options) + { + AddOptionMenuItem(menu, option); + } + } + + // Device operations (from default device) + if (_extension.SelectedProvider?.DefaultDevice != null && _extension.SelectedProvider.DefaultDevice.Operations.Count > 0) + { + menu.Items.Add(new Separator()); + + foreach (var operation in _extension.SelectedProvider.DefaultDevice.Operations) + { + var opItem = new MenuItem + { + Header = operation.DisplayName, + IsEnabled = operation.CanExecute + }; + var capturedOp = operation; + opItem.Click += async (s, e) => + { + if (capturedOp.CanExecute) + await capturedOp.ExecuteAsync(); + }; + menu.Items.Add(opItem); + } + } + } + + private void AddOptionMenuItem(ContextMenu menu, IProviderOption option) + { + if (option.Kind == ProviderOptionKind.Dropdown) + { + var optionMenu = new MenuItem { Header = option.DisplayName }; + foreach (var val in option.AvailableValues) + { + var isActive = Equals(option.Value, val); + var valItem = new MenuItem + { + Header = val.ToString(), + Icon = isActive ? new TextBlock { Text = "\u2713" } : null + }; + var capturedVal = val; + var capturedOption = option; + valItem.Click += (s, e) => { capturedOption.Value = capturedVal; }; + optionMenu.Items.Add(valItem); + } + menu.Items.Add(optionMenu); + } + else if (option.Kind == ProviderOptionKind.Toggle) + { + var toggleItem = new MenuItem + { + Header = option.DisplayName, + Icon = Equals(option.Value, true) ? new TextBlock { Text = "\u2713" } : null + }; + var capturedOption = option; + toggleItem.Click += (s, e) => { capturedOption.Value = !Equals(capturedOption.Value, true); }; + menu.Items.Add(toggleItem); + } + } + } +} diff --git a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.xaml b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.xaml deleted file mode 100644 index 084b9fb..0000000 --- a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.xaml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.xaml.cs b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.xaml.cs deleted file mode 100644 index 6f36001..0000000 --- a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtensionView.xaml.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; - -namespace EmoTracker.Extensions.AutoTracker -{ - /// - /// Interaction logic for ConnectorLibExtensionView.xaml - /// - public partial class AutoTrackerExtensionView : UserControl - { - public AutoTrackerExtensionView() - { - InitializeComponent(); - } - } -} diff --git a/EmoTracker/Extensions/AutoTracker/IUpdateWithConnector.cs b/EmoTracker/Extensions/AutoTracker/IUpdateWithConnector.cs index e6a185a..56edc2e 100644 --- a/EmoTracker/Extensions/AutoTracker/IUpdateWithConnector.cs +++ b/EmoTracker/Extensions/AutoTracker/IUpdateWithConnector.cs @@ -1,24 +1,18 @@ -using ConnectorLib; -using EmoTracker.Data.Packages; -using NLua; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static EmoTracker.Extensions.AutoTracker.MemorySegment; - -namespace EmoTracker.Extensions.AutoTracker -{ - internal interface IUpdateWithConnector - { - [LuaHide] - void MarkDirty(); - - [LuaHide] - bool ShouldUpdate(DateTime now); - - [LuaHide] - MemoryUpdateResult UpdateWithConnector(IAddressableConnector connector, PackageManager.Game game); - } -} +using EmoTracker.Data.AutoTracking; +using EmoTracker.Data.Packages; +using NLua; + +namespace EmoTracker.Extensions.AutoTracker +{ + internal interface IUpdateWithConnector + { + [LuaHide] + void MarkDirty(); + + [LuaHide] + bool ShouldUpdate(System.DateTime now); + + [LuaHide] + MemoryUpdateResult UpdateWithConnector(IAutoTrackingProvider provider, PackageManager.Game game); + } +} diff --git a/EmoTracker/Extensions/AutoTracker/MemorySegment.cs b/EmoTracker/Extensions/AutoTracker/MemorySegment.cs index a26575e..83d178f 100644 --- a/EmoTracker/Extensions/AutoTracker/MemorySegment.cs +++ b/EmoTracker/Extensions/AutoTracker/MemorySegment.cs @@ -1,312 +1,302 @@ -using ConnectorLib; -using EmoTracker.Data; -using EmoTracker.Data.Packages; -using EmoTracker.Data.Scripting; -using NLua; -using System; -using System.Windows; - -namespace EmoTracker.Extensions.AutoTracker -{ - public class MemorySegment : IMemorySegment, IUpdateWithConnector, IDisposable - { - #region -- Global Event Hooks -- - - public delegate void MemorySegmentUpdatedHandler(MemorySegment segment, IAddressableConnector connector, PackageManager.Game game); - - /// - /// Invoked when a memory segment's contents (in watched memory) have changed - /// - public static event MemorySegmentUpdatedHandler OnMemorySegmentModified; - - /// - /// Invoked when a memory segment's contents have been read from watched memory - /// - public static event MemorySegmentUpdatedHandler OnMemorySegmentUpdated; - - #endregion - - Func mCallback; - Action mDisposeCallback; - string mName; - - DateTime mLastUpdate; - int mPeriod = 500; - ulong mStartAddress; - ulong mLength; - bool mbDirty; - bool mbFrozen; - byte[][] mBuffers; - - public string Name - { - get { return mName; } - } - - public int Period - { - get { return mPeriod; } - } - - public ulong StartAddress - { - get { return mStartAddress; } - } - - public ulong EndAddress - { - get { return mStartAddress + (Length - 1); } - } - - public ulong Length - { - get { return mLength; } - } - - public bool Dirty - { - get { lock (this) { return mbDirty; } } - set { lock (this) { mbDirty = value; } } - } - - public bool Frozen - { - get { return mbFrozen; } - protected set { mbFrozen = value; } - } - - private byte[] ReadBuffer - { - get { return mBuffers[0]; } - } - - private byte[] WriteBuffer - { - get { return mBuffers[1]; } - } - - public bool ContainsAddress(ulong address) - { - if (address >= mStartAddress) - { - ulong offset = address - mStartAddress; - if (offset < mLength) - return true; - } - - return false; - } - - private ulong GetOffsetForAddress(ulong address) - { - if (address >= mStartAddress) - { - ulong offset = address - mStartAddress; - if (offset < mLength) - return offset; - } - - throw new InvalidOperationException("Address is not contained within this segment"); - } - - public byte ReadUInt8(ulong address, bool bRawRead = false) - { - if (ReadBuffer != null) - { - try - { - ulong offset = GetOffsetForAddress(address); - return ReadBuffer[offset]; - } - catch - { - ScriptManager.Instance.OutputError("Address 0x{0:x} is out of range of segment '{3}' = [0x{1:x}:0x{2:x}]", address, StartAddress, StartAddress + Length, Name); - } - } - - return 0; - } - - public sbyte ReadInt8(ulong address, bool bRawRead = false) - { - return unchecked((sbyte)ReadUInt8(address)); - } - - public ushort ReadUInt16(ulong address, bool bRawRead = false) - { - if (ReadBuffer != null) - { - try - { - ulong offset = GetOffsetForAddress(address); - - byte b0 = ReadUInt8(address); - byte b1 = ReadUInt8(address + 1); - - ushort value = (ushort)((uint)b1 << 8 | b0); - return value; - } - catch - { - ScriptManager.Instance.OutputError("Address 0x{0:x} is out of range of segment '{3}' = [0x{1:x}:0x{2:x}]", address, StartAddress, StartAddress + Length, Name); - } - } - - return 0; - } - - public short ReadInt16(ulong address, bool bRawRead = false) - { - return 0; - } - - public MemorySegment(string name, ulong startAddress, ulong length, Func callback, Action disposeCallback, int period = 500) - { - if (length == 0) - throw new InvalidOperationException("Buffer must have non-zero size"); - - mName = name; - mbDirty = true; - mCallback = callback; - mDisposeCallback = disposeCallback; - mStartAddress = startAddress; - mLength = length; - mPeriod = period; - mBuffers = new byte[2][]; - mBuffers[0] = new byte[length]; - mBuffers[1] = new byte[length]; - } - - public void Freeze() - { - Frozen = true; - } - - public void Unfreeze() - { - Frozen = false; - } - - [LuaHide] - public bool ShouldUpdate(DateTime now) - { - lock (this) - { - if (Frozen) - return false; - - if (Dirty) - return true; - - if (mLastUpdate.ToBinary() != 0) - { - if ((now - mLastUpdate).CompareTo(TimeSpan.FromMilliseconds(Period)) < 0) - return false; - } - - return true; - } - } - - [LuaHide] - public MemoryUpdateResult UpdateWithConnector(IAddressableConnector connector, PackageManager.Game game) - { - if (Frozen) - return MemoryUpdateResult.Success; - - // System.Diagnostics.Debug.Print("Updating segment {0} :: {1:H:mm:ss:fff}", Name, DateTime.Now); - -#if false - if (game == null) - return UpdateResult.MissingGameData; - - if (!game.IsMemoryRangeAccessAllowed(StartAddress, EndAddress)) - return UpdateResult.InvalidAccess; -#endif - - try - { - bool bReadResult = false; - { - int attempts = 0; - while (attempts < 5 && !bReadResult) - { - try - { - bReadResult = connector.Read(StartAddress, WriteBuffer); - } - catch { } - - ++attempts; - } - } - - if (bReadResult) - { - bool bModified = Dirty; - for (ulong i = 0; !bModified && i < Length; ++i) - { - if (ReadBuffer[i] != WriteBuffer[i]) - { - bModified = true; - break; - } - } - - if (bModified) - { - lock (this) - { - Buffer.BlockCopy(WriteBuffer, 0, ReadBuffer, 0, (int)Length); - - // Invoke the segment modified handler - OnMemorySegmentModified?.Invoke(this, connector, game); - - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - lock (this) - { - bool bResult = true; - if (mCallback != null) - { - bResult = mCallback(this); - } - - if (bResult) - Dirty = false; - - DateTime now = DateTime.Now; - mLastUpdate = now; - } - })); - } - } - - // Invoke the segment updated handler - OnMemorySegmentUpdated?.Invoke(this, connector, game); - - return MemoryUpdateResult.Success; - } - } - catch - { - } - - return MemoryUpdateResult.Error; - } - - public void Dispose() - { - lock (this) - { - if (mDisposeCallback != null) - mDisposeCallback(this); - } - } - - public void MarkDirty() - { - Dirty = true; - } - } -} +using EmoTracker.Core.Services; +using EmoTracker.Data; +using EmoTracker.Data.AutoTracking; +using EmoTracker.Data.Packages; +using EmoTracker.Data.Scripting; +using NLua; +using System; + +namespace EmoTracker.Extensions.AutoTracker +{ + public class MemorySegment : IMemorySegment, IUpdateWithConnector, IDisposable + { + #region -- Global Event Hooks -- + + public delegate void MemorySegmentUpdatedHandler(MemorySegment segment, IAutoTrackingProvider provider, PackageManager.Game game); + + /// + /// Invoked when a memory segment's contents (in watched memory) have changed + /// + public static event MemorySegmentUpdatedHandler OnMemorySegmentModified; + + /// + /// Invoked when a memory segment's contents have been read from watched memory + /// + public static event MemorySegmentUpdatedHandler OnMemorySegmentUpdated; + + #endregion + + Func mCallback; + Action mDisposeCallback; + string mName; + + DateTime mLastUpdate; + int mPeriod = 500; + ulong mStartAddress; + ulong mLength; + bool mbDirty; + bool mbFrozen; + byte[][] mBuffers; + + public string Name + { + get { return mName; } + } + + public int Period + { + get { return mPeriod; } + } + + public ulong StartAddress + { + get { return mStartAddress; } + } + + public ulong EndAddress + { + get { return mStartAddress + (Length - 1); } + } + + public ulong Length + { + get { return mLength; } + } + + public bool Dirty + { + get { lock (this) { return mbDirty; } } + set { lock (this) { mbDirty = value; } } + } + + public bool Frozen + { + get { return mbFrozen; } + protected set { mbFrozen = value; } + } + + private byte[] ReadBuffer + { + get { return mBuffers[0]; } + } + + private byte[] WriteBuffer + { + get { return mBuffers[1]; } + } + + public bool ContainsAddress(ulong address) + { + if (address >= mStartAddress) + { + ulong offset = address - mStartAddress; + if (offset < mLength) + return true; + } + + return false; + } + + private ulong GetOffsetForAddress(ulong address) + { + if (address >= mStartAddress) + { + ulong offset = address - mStartAddress; + if (offset < mLength) + return offset; + } + + throw new InvalidOperationException("Address is not contained within this segment"); + } + + public byte ReadUInt8(ulong address, bool bRawRead = false) + { + if (ReadBuffer != null) + { + try + { + ulong offset = GetOffsetForAddress(address); + return ReadBuffer[offset]; + } + catch + { + ScriptManager.Instance.OutputError("Address 0x{0:x} is out of range of segment '{3}' = [0x{1:x}:0x{2:x}]", address, StartAddress, StartAddress + Length, Name); + } + } + + return 0; + } + + public sbyte ReadInt8(ulong address, bool bRawRead = false) + { + return unchecked((sbyte)ReadUInt8(address)); + } + + public ushort ReadUInt16(ulong address, bool bRawRead = false) + { + if (ReadBuffer != null) + { + try + { + ulong offset = GetOffsetForAddress(address); + + byte b0 = ReadUInt8(address); + byte b1 = ReadUInt8(address + 1); + + ushort value = (ushort)((uint)b1 << 8 | b0); + return value; + } + catch + { + ScriptManager.Instance.OutputError("Address 0x{0:x} is out of range of segment '{3}' = [0x{1:x}:0x{2:x}]", address, StartAddress, StartAddress + Length, Name); + } + } + + return 0; + } + + public short ReadInt16(ulong address, bool bRawRead = false) + { + return 0; + } + + public MemorySegment(string name, ulong startAddress, ulong length, Func callback, Action disposeCallback, int period = 500) + { + if (length == 0) + throw new InvalidOperationException("Buffer must have non-zero size"); + + mName = name; + mbDirty = true; + mCallback = callback; + mDisposeCallback = disposeCallback; + mStartAddress = startAddress; + mLength = length; + mPeriod = period; + mBuffers = new byte[2][]; + mBuffers[0] = new byte[length]; + mBuffers[1] = new byte[length]; + } + + public void Freeze() + { + Frozen = true; + } + + public void Unfreeze() + { + Frozen = false; + } + + [LuaHide] + public bool ShouldUpdate(DateTime now) + { + lock (this) + { + if (Frozen) + return false; + + if (Dirty) + return true; + + if (mLastUpdate.ToBinary() != 0) + { + if ((now - mLastUpdate).CompareTo(TimeSpan.FromMilliseconds(Period)) < 0) + return false; + } + + return true; + } + } + + [LuaHide] + public MemoryUpdateResult UpdateWithConnector(IAutoTrackingProvider provider, PackageManager.Game game) + { + if (Frozen) + return MemoryUpdateResult.Success; + + try + { + bool bReadResult = false; + { + int attempts = 0; + while (attempts < 5 && !bReadResult) + { + try + { + bReadResult = provider.Read(StartAddress, WriteBuffer); + } + catch { } + + ++attempts; + } + } + + if (bReadResult) + { + bool bModified = Dirty; + for (ulong i = 0; !bModified && i < Length; ++i) + { + if (ReadBuffer[i] != WriteBuffer[i]) + { + bModified = true; + break; + } + } + + if (bModified) + { + lock (this) + { + Buffer.BlockCopy(WriteBuffer, 0, ReadBuffer, 0, (int)Length); + + // Invoke the segment modified handler + OnMemorySegmentModified?.Invoke(this, provider, game); + + Dispatch.BeginInvoke(() => + { + lock (this) + { + bool bResult = true; + if (mCallback != null) + { + bResult = mCallback(this); + } + + if (bResult) + Dirty = false; + + DateTime now = DateTime.Now; + mLastUpdate = now; + } + }); + } + } + + // Invoke the segment updated handler + OnMemorySegmentUpdated?.Invoke(this, provider, game); + + return MemoryUpdateResult.Success; + } + } + catch + { + } + + return MemoryUpdateResult.Error; + } + + public void Dispose() + { + lock (this) + { + if (mDisposeCallback != null) + mDisposeCallback(this); + } + } + + public void MarkDirty() + { + Dirty = true; + } + } +} diff --git a/EmoTracker/Extensions/AutoTracker/MemoryTimer.cs b/EmoTracker/Extensions/AutoTracker/MemoryTimer.cs index 20f5172..69b6e43 100644 --- a/EmoTracker/Extensions/AutoTracker/MemoryTimer.cs +++ b/EmoTracker/Extensions/AutoTracker/MemoryTimer.cs @@ -1,83 +1,75 @@ -using ConnectorLib; -using EmoTracker.Core; -using EmoTracker.Data; -using EmoTracker.Data.Packages; -using EmoTracker.Data.Scripting; -using NLua; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; - -namespace EmoTracker.Extensions.AutoTracker -{ - public class MemoryTimer : IUpdateWithConnector, IDisposable - { - string mName; - Func mCallback; - DateTime mLastUpdate; - int mPeriod = 500; - - public string Name - { - get { return mName; } - } - - public int Period - { - get { return mPeriod; } - } - - public MemoryTimer(string name, Func callback, int period = 500) - { - mName = name; - mCallback = callback; - mPeriod = period; - } - - [LuaHide] - public bool ShouldUpdate(DateTime now) - { - lock (this) - { - if (mLastUpdate.ToBinary() != 0) - { - if ((now - mLastUpdate).CompareTo(TimeSpan.FromMilliseconds(Period)) < 0) - return false; - } - - return true; - } - } - - [LuaHide] - public MemoryUpdateResult UpdateWithConnector(IAddressableConnector connector, PackageManager.Game game) - { - try - { - lock (this) - { - mLastUpdate = DateTime.Now; - } - - if (mCallback != null) - return mCallback(connector, game) ? MemoryUpdateResult.Success : MemoryUpdateResult.Error; - } - catch - { - } - - return MemoryUpdateResult.Error; - } - - public void MarkDirty() - { - } - - public void Dispose() - { - } - } -} +using EmoTracker.Data.AutoTracking; +using EmoTracker.Data.Packages; +using NLua; +using System; + +namespace EmoTracker.Extensions.AutoTracker +{ + public class MemoryTimer : IUpdateWithConnector, IDisposable + { + string mName; + Func mCallback; + DateTime mLastUpdate; + int mPeriod = 500; + + public string Name + { + get { return mName; } + } + + public int Period + { + get { return mPeriod; } + } + + public MemoryTimer(string name, Func callback, int period = 500) + { + mName = name; + mCallback = callback; + mPeriod = period; + } + + [LuaHide] + public bool ShouldUpdate(DateTime now) + { + lock (this) + { + if (mLastUpdate.ToBinary() != 0) + { + if ((now - mLastUpdate).CompareTo(TimeSpan.FromMilliseconds(Period)) < 0) + return false; + } + + return true; + } + } + + [LuaHide] + public MemoryUpdateResult UpdateWithConnector(IAutoTrackingProvider provider, PackageManager.Game game) + { + try + { + lock (this) + { + mLastUpdate = DateTime.Now; + } + + if (mCallback != null) + return mCallback(provider, game) ? MemoryUpdateResult.Success : MemoryUpdateResult.Error; + } + catch + { + } + + return MemoryUpdateResult.Error; + } + + public void MarkDirty() + { + } + + public void Dispose() + { + } + } +} diff --git a/EmoTracker/Extensions/AutoTracker/MemoryUpdateResult.cs b/EmoTracker/Extensions/AutoTracker/MemoryUpdateResult.cs deleted file mode 100644 index ac95dab..0000000 --- a/EmoTracker/Extensions/AutoTracker/MemoryUpdateResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace EmoTracker.Extensions.AutoTracker -{ - public enum MemoryUpdateResult - { - Success, - Error, - MissingGameData, - InvalidAccess - }; -} diff --git a/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs b/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs deleted file mode 100644 index 6905213..0000000 --- a/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs +++ /dev/null @@ -1,1645 +0,0 @@ -using ConnectorLib; -using EmoTracker.Core; -using EmoTracker.Data; -using EmoTracker.Data.JSON; -using EmoTracker.Data.Packages; -using EmoTracker.Extensions.AutoTracker; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Windows; -using WebSocketSharp; - -namespace EmoTracker.Extensions.BontaMultiworld -{ - #region -- Command Attribute -- - - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public class CommandAttribute : Attribute - { - public string Command - { - get; set; - } - - public CommandAttribute(string command) - { - Command = command; - } - } - - #endregion - - public enum SessionLoginType - { - Unknown, - Legacy, - RomBased - } - - public enum SessionStatus - { - Disconnected, - Connecting, - Connected, - Authenticated, - Ready - } - - [Flags] - public enum AuthenticationFailure - { - None = 0x00, - InvalidName = 0x01, - InvalidPassword = 0x02, - InvalidTeam = 0x04, - InvalidSlot = 0x08, - SlotAlreadyTaken = 0x10, - NameAlreadyTaken = 0x20, - InvalidRom = 0x40 - } - - public enum SessionError - { - None, - ConnectionError, - ProtocolError, - RomValidationError - } - - public class MultiWorldClientSession : ObservableObject - { - #region -- Message Log -- - - protected ObservableCollection mMessageLog = new ObservableCollection(); - public IReadOnlyList MessageLog - { - get { return mMessageLog; } - } - - public void ClearMessageLog(object arg = null) - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - mMessageLog.Clear(); - })); - } - - public void Log(string format, params object[] tokens) - { - string formattedMsg = string.Format(format, tokens); - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - mMessageLog.Add(formattedMsg); - })); - } - - #endregion - - #region -- Socket -- - - WebSocket mSocket; - protected WebSocket Socket - { - get { return mSocket; } - set - { - WebSocket existingSocket = mSocket; - if (SetProperty(ref mSocket, value)) - { - if (existingSocket != null) - { - try - { - existingSocket.OnOpen -= Socket_OnOpen; - existingSocket.OnClose -= Socket_OnClose; - existingSocket.OnError -= Socket_OnError; - existingSocket.OnMessage -= Socket_OnMessage; - existingSocket.Close(); - } - catch { } - } - - if (mSocket != null) - { - mSocket.OnOpen += Socket_OnOpen; - mSocket.OnClose += Socket_OnClose; - mSocket.OnError += Socket_OnError; - mSocket.OnMessage += Socket_OnMessage; - } - } - } - } - - #endregion - - #region -- Status Info -- - - SessionLoginType mSessionLoginType = SessionLoginType.Unknown; - public SessionLoginType SessionLoginType - { - get { return mSessionLoginType; } - set { SetProperty(ref mSessionLoginType, value); } - } - - SessionStatus mSessionStatus = SessionStatus.Disconnected; - public SessionStatus SessionStatus - { - get { return mSessionStatus; } - set { SetProperty(ref mSessionStatus, value); } - } - - SessionError mSessionError = SessionError.None; - public SessionError Error - { - get { return mSessionError; } - set { SetProperty(ref mSessionError, value); } - } - - AuthenticationFailure mAuthFailure = AuthenticationFailure.None; - public AuthenticationFailure AuthenticationFailure - { - get { return mAuthFailure; } - set { SetProperty(ref mAuthFailure, value); } - } - - #endregion - - #region -- Session Description -- - - public class SessionDescription : ObservableObject - { - bool mbPasswordRequired = false; - public bool PasswordRequired - { - get { return mbPasswordRequired; } - private set { SetProperty(ref mbPasswordRequired, value); } - } - - public class Slot : ObservableObject - { - string mPlayerName; - string mTeamName; - int mIndex = -1; - bool mbAvailable = true; - - public string PlayerName - { - get { return mPlayerName; } - set { SetProperty(ref mPlayerName, value); } - } - - public string TeamName - { - get { return mTeamName; } - set { SetProperty(ref mTeamName, value); } - } - - [DependentProperty("DisplayIndex")] - public int Index - { - get { return mIndex; } - set { SetProperty(ref mIndex, value); } - } - - public int ServerIndex - { - get { return Index; } - } - - public int DisplayIndex - { - get { return Index; } - } - - public bool Available - { - get { return mbAvailable; } - set { SetProperty(ref mbAvailable, value); } - } - } - - ObservableCollection mSlots = new ObservableCollection(); - public IReadOnlyList Slots - { - get { return mSlots; } - } - - public int PlayerCount - { - get { return mSlots.Count; } - private set - { - mSlots.Clear(); - for (int i = 0; i < value; ++i) - { - mSlots.Add(new Slot() - { - Index = i + 1 - }); - } - } - } - - public SessionDescription(JToken data, MultiWorldClientSession session) - { - JObject dataObject = data as JObject; - - if (dataObject == null) - throw new InvalidDataException("Unsupported data format for session description"); - - PlayerCount = dataObject.GetValue("slots"); - PasswordRequired = dataObject.GetValue("password", false); - - if (PlayerCount > 0 || PasswordRequired) - { - session.Log("--------------------------------"); - session.Log("Room Information:"); - session.Log("--------------------------------"); - } - - if (PlayerCount > 0) - session.Log("{0} player seed", PlayerCount); - - if (PasswordRequired) - session.Log("Password Required"); - } - } - - SessionDescription mDescription; - public SessionDescription Description - { - get { return mDescription; } - protected set { SetProperty(ref mDescription, value); } - } - - #endregion - - #region -- Authentication -- - - byte[] mExpectedRom; - - string mHostURI = "127.0.0.1:38281"; - public string HostUri - { - get { return mHostURI; } - set { SetProperty(ref mHostURI, value); } - } - - string mUserName; - public string UserName - { - get { return mUserName; } - set { SetProperty(ref mUserName, value); } - } - - string mPassword; - public string Password - { - get { return mPassword; } - set { SetProperty(ref mPassword, value); } - } - - string mUserTeam; - public string UserTeam - { - get { return mUserTeam; } - set { SetProperty(ref mUserTeam, value); } - } - - SessionDescription.Slot mUserSlot; - public SessionDescription.Slot UserSlot - { - get { return mUserSlot; } - set { SetProperty(ref mUserSlot, value); } - } - - protected bool CanAuthenticate(object arg = null) - { - if (SessionStatus != SessionStatus.Connected) - return false; - - if (Description == null) - return false; - - if (Description.PasswordRequired && string.IsNullOrWhiteSpace(Password)) - return false; - - if (SessionLoginType == SessionLoginType.Legacy) - { - if (string.IsNullOrWhiteSpace(UserName)) - return false; - - if (UserSlot == null || !UserSlot.Available) - return false; - } - - return true; - } - - protected void Authenticate(object arg = null) - { - if (CanAuthenticate()) - { - object abstractMessage; - - if (SessionLoginType == SessionLoginType.Legacy) - { - LegacyConnectMsg msg = new LegacyConnectMsg(); - msg.name = UserName; - msg.password = !string.IsNullOrWhiteSpace(Password) ? Password : null; - msg.team = !string.IsNullOrWhiteSpace(UserTeam) ? UserTeam : null; - msg.slot = UserSlot.Index; - - abstractMessage = msg; - } - else - { - ConnectMsg msg = new ConnectMsg(); - - mExpectedRom = GetRomHash(); - msg.rom = new List(mExpectedRom); - msg.password = !string.IsNullOrWhiteSpace(Password) ? Password : null; - - abstractMessage = msg; - } - - Message message = new Message() - { - Command = "Connect", - Data = JObject.FromObject(abstractMessage) - }; - - SendMessages(JoinSessionCompletionHandler, message); - } - } - - Dictionary mTeamPlayerNameMap = new Dictionary(); - - [Command("Connected")] - private void OnAuthenticatedCmd(JToken data) - { - AutoTrackerExtension extension = ExtensionManager.Instance.FindExtension(); - if (extension != null) - { - if (SessionLoginType == SessionLoginType.Legacy) - { - JArray romContainer = data as JArray; - if (romContainer != null && romContainer.Count <= 0x15) - { - mExpectedRom = new byte[romContainer.Count]; - for (int i = 0; i < romContainer.Count; ++i) - { - mExpectedRom[i] = romContainer[i].GetValue(); - } - } - else - { - mExpectedRom = null; - } - - if (!IsRomValid()) - { - Disconnect(SessionError.RomValidationError); - return; - } - } - else - { - try - { - JArray root = data as JArray; - if (root != null && root.Count >= 2) - { - JArray playerMap = root[1] as JArray; - if (playerMap != null) - { - foreach (JArray entry in playerMap) - { - try - { - int playerIdx = entry[0].GetValue(); - string playerName = entry[1].GetValue(); - - mTeamPlayerNameMap[playerIdx] = playerName; - } - catch - { - } - } - } - } - } - catch - { - } - } - - mMemoryTimer = extension.AddMemoryTimer("Received Items Hook", WriteReceivedItems, 1000); - MemorySegment.OnMemorySegmentUpdated += MemorySegment_OnMemorySegmentUpdated; - - SessionStatus = SessionStatus.Authenticated; - - SendLocationChecks(mCheckedLocations); - } - } - - [Command("ConnectionRefused")] - private void OnAuthenticationFailedCmd(JToken data) - { - JArray serverErrors = data as JArray; - if (serverErrors != null && serverErrors.Count > 0) - { - AuthenticationFailure failureState = AuthenticationFailure.None; - - foreach (JToken serverError in serverErrors) - { - string errorCode = serverError.GetValue(); - if (!string.IsNullOrWhiteSpace(errorCode)) - { - switch (errorCode) - { - case "InvalidRom": - failureState = failureState | AuthenticationFailure.InvalidRom; - break; - - case "InvalidPassword": - failureState = failureState | AuthenticationFailure.InvalidPassword; - break; - - case "InvalidName": - failureState = failureState | AuthenticationFailure.InvalidName; - break; - - case "NameAlreadyTaken": - failureState = failureState | AuthenticationFailure.NameAlreadyTaken; - break; - - case "InvalidTeam": - failureState = failureState | AuthenticationFailure.InvalidTeam; - break; - - case "InvalidSlot": - failureState = failureState | AuthenticationFailure.InvalidSlot; - break; - - case "SlotAlreadyTaken": - failureState = failureState | AuthenticationFailure.SlotAlreadyTaken; - break; - } - } - } - - AuthenticationFailure = failureState; - - if (failureState != AuthenticationFailure.None) - Password = null; - - if (failureState.HasFlag(AuthenticationFailure.InvalidRom)) - Disconnect(SessionError.RomValidationError); - } - else - { - Disconnect(SessionError.ProtocolError); - } - } - - #endregion - - #region -- Core Socket Handlers -- - - Dictionary mMethodCache = new Dictionary(); - - private void InvokeCommand(string command, JToken data) - { - if (!string.IsNullOrWhiteSpace(command)) - { - MethodInfo method = null; - if (!mMethodCache.TryGetValue(command, out method)) - { - bool bFound = false; - - Type currentType = this.GetType(); - while (currentType != null && !bFound) - { - var methods = currentType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (MethodInfo candidateMethod in methods) - { - CommandAttribute cmdAttr = candidateMethod.GetCustomAttribute(); - if (cmdAttr != null && string.Equals(command, cmdAttr.Command, StringComparison.OrdinalIgnoreCase)) - { - bFound = true; - method = candidateMethod; - mMethodCache[command] = method; - break; - } - } - - currentType = currentType.BaseType; - } - - // Cache null to avoid searching again - if (!bFound) - mMethodCache[command] = null; - } - - if (method != null) - method.Invoke(this, new object[] { data }); - else - Log("Unknown Command: {0} :: {1}", command, data != null ? data.ToString() : ""); - } - } - - private void Socket_OnMessage(object sender, MessageEventArgs e) - { - try - { - - using (StringReader reader = new StringReader(e.Data)) - { - JArray commandList = JToken.ReadFrom(new JsonTextReader(reader)) as JArray; - if (commandList != null) - { - foreach (JArray command in commandList) - { - string cmd = null; - JToken data = null; - - try - { - if (command.Count > 0) - cmd = command[0].Value(); - - if (command.Count > 1) - data = command[1]; - - InvokeCommand(cmd, data); - } - catch (Exception ex) - { - Log("Exception occured while processing command: {0}", e.Data); - Log(ex.ToString()); - } - } - } - } - } - catch - { - } - } - - private void Socket_OnClose(object sender, CloseEventArgs e) - { - System.Diagnostics.Debug.Print("Session Closed: {0}", e.Reason); - Disconnect(); - } - - private void Socket_OnError(object sender, WebSocketSharp.ErrorEventArgs e) - { - System.Diagnostics.Debug.Print("Session Error: {0}", e.Message); - Disconnect(SessionError.ConnectionError); - } - - private void Socket_OnOpen(object sender, EventArgs e) - { - System.Diagnostics.Debug.Print("Session Opened"); - } - -#endregion - - #region -- Message Send -- - - class Message - { - public string Command; - public JToken Data; - } - - private void SendMessages(Action completionHandler, params Message[] msgs) - { - SendMessages(msgs.AsEnumerable(), completionHandler); - } - - private void SendMessages(IEnumerable msgs, Action completionHandler) - { - JArray batch = new JArray(); - foreach (Message msg in msgs) - { - if (msg != null) - { - JArray msgArray = new JArray(); - msgArray.Add(JToken.FromObject(msg.Command)); - if (msg.Data != null) - msgArray.Add(msg.Data); - - batch.Add(msgArray); - } - } - - if (batch.Count > 0) - { - using (StringWriter writer = new StringWriter()) - { - using (JsonTextWriter jsonWriter = new JsonTextWriter(writer)) - { - jsonWriter.AutoCompleteOnClose = true; - jsonWriter.Formatting = Formatting.None; - - jsonWriter.WriteToken(batch.CreateReader()); - jsonWriter.Close(); - } - - string messageString = writer.ToString(); - - if (Socket != null) - { - lock (Socket) - { - Socket.SendAsync(messageString, completionHandler); - } - } - } - } - } - -#endregion - - public bool CanConnect(object arg = null) - { - if (Socket != null) - return false; - - if (string.IsNullOrWhiteSpace(HostUri)) - return false; - - AutoTrackerExtension autotracker = ExtensionManager.Instance.FindExtension(); - if (autotracker == null || autotracker.ActiveConnector == null || !autotracker.ActiveConnector.Connected) - return false; - - return true; - } - - public void Connect() - { - try - { - if (!CanConnect()) - return; - - string uri = HostUri; - if (!uri.StartsWith("ws://") && !uri.StartsWith("wss://")) - uri = "ws://" + uri; - - Socket = new WebSocket(uri); - SessionStatus = SessionStatus.Connecting; - Socket.ConnectAsync(); - } - catch - { - Disconnect(SessionError.ConnectionError); - } - } - - public void Disconnect(object arg) - { - Disconnect(); - } - - public void Disconnect(SessionError error = SessionError.None) - { - bool bHadSocket = false; - - if (Socket != null) - { - bHadSocket = true; - - lock (Socket) - { - Socket = null; - } - } - - ResetConnectionState(); - - SessionLoginType = SessionLoginType.Unknown; - AuthenticationFailure = AuthenticationFailure.None; - SessionStatus = SessionStatus.Disconnected; - Password = null; - mTeamPlayerNameMap.Clear(); - Error = error; - - switch (Error) - { - case SessionError.None: - { - if (bHadSocket) - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You have been disconnected from the multi-world server.")); - - })); - } - } - break; - - case SessionError.RomValidationError: - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You were disconnected from the multi-world server because your ROM does not match the server's expectations.")); - - })); - } - break; - - case SessionError.ProtocolError: - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You were disconnected from the multi-world server because the server responded to a request in an unexpected way.")); - - })); - } - break; - - default: - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You have been disconnected from the multi-world server.")); - - })); - } - break; - } - } - -#region -- Session Join -- - - class LegacyConnectMsg - { - public string password { get; set; } - public string name { get; set; } - public string team { get; set; } - public int slot { get; set; } - } - - class ConnectMsg - { - public List rom { get; set; } - public string password { get; set; } - } - - private void JoinSessionCompletionHandler(bool result) - { - if (!result) - Log("Failed to send connect message"); - } - -#endregion - - - [Command("Print")] - private void OnPrintCmd(JToken data) - { - if (data != null && !string.IsNullOrWhiteSpace(data.ToString())) - Log(data.ToString()); - } - - [Command("RoomInfo")] - private void OnRoomInfoCmd(JToken data) - { - try - { - Description = new SessionDescription(data, this); - - foreach (SessionDescription.Slot slot in Description.Slots) - { - if (UserSlot == null && slot.Available) - UserSlot = slot; - } - - if (Description.Slots.Count == 0) - SessionLoginType = SessionLoginType.RomBased; - else - SessionLoginType = SessionLoginType.Legacy; - - SessionStatus = SessionStatus.Connected; - - if (CanAuthenticate()) - Authenticate(); - } - catch - { - Log("Invalid session description format"); - Disconnect(SessionError.ProtocolError); - - return; - } - } - - MemoryTimer mMemoryTimer; - - private void ResetConnectionState() - { - ClearMessageLog(); - mReceivedItems.Clear(); - mCheckedLocations.Clear(); - - AutoTrackerExtension extension = ExtensionManager.Instance.FindExtension(); - if (extension != null) - { - if (mMemoryTimer == null) - { - extension.RemoveMemoryTimer(mMemoryTimer); - mMemoryTimer = null; - } - } - - AuthenticationFailure = AuthenticationFailure.None; - - UserSlot = null; - Description = null; - mExpectedRom = null; - } - - class ReceivedItem - { - public int Item { get; set; } - public string Location { get; set; } - public string PlayerName { get; set; } - } - - List mReceivedItems = new List(); - - private IAddressableConnector ActiveConnector - { - get - { - AutoTrackerExtension autotracker = ExtensionManager.Instance.FindExtension(); - if (autotracker == null || autotracker.ActiveConnector == null || !autotracker.ActiveConnector.Connected) - return null; - - return autotracker.ActiveConnector; - } - } - - private byte[] GetRomHash(IAddressableConnector connector = null) - { - connector = connector ?? ActiveConnector; - - if (connector == null) - return null; - - byte[] romHash = new byte[0x15]; - if (!connector.Read(0x702000, romHash)) - return null; - - return romHash; - } - - private bool IsRomValid(IAddressableConnector connector = null) - { - connector = connector ?? ActiveConnector; - - if (mExpectedRom == null || mExpectedRom.Length > 0x15) - return false; - - if (ApplicationSettings.Instance.IgnoreBontaMultiWorldRomCheck) - return true; - - byte[] romHash = GetRomHash(); - if (romHash == null || romHash.Length < mExpectedRom.Length) - return false; - - for (int i = 0; i < mExpectedRom.Length; ++i) - { - if (romHash[i] != mExpectedRom[i]) - return false; - } - - return true; - } - - private bool IsInGame(IAddressableConnector connector = null) - { - connector = connector ?? ActiveConnector; - - if (!IsRomValid(connector)) - return false; - - I8BitConnector connector8 = connector as I8BitConnector; - if (connector8 == null) - return false; - - byte gameState; - if (!connector8.Read8(0x7e0010, out gameState)) - return false; - - switch (gameState) - { - case 0x07: - case 0x09: - case 0x0b: - return true; - - default: - return false; - } - } - - [Command("ItemSent")] - private void OnItemSent(JToken data) - { - JArray container = data as JArray; - if (container != null && container.Count == 4) - { - try - { - string userFrom = "Unknown Player"; - string userTo = "Unknown Player"; - int itemCode = -1; - int locationCode = -1; - - if (SessionLoginType == SessionLoginType.Legacy) - { - userFrom = container[0].Value(); - userTo = container[1].Value(); - itemCode = container[2].Value(); - locationCode = container[3].Value(); - } - else - { - int fromPlayerIdx = container[0].Value(); - int toPlayerIdx = container[2].Value(); - - userFrom = GetPlayerNameForIndex(fromPlayerIdx); - userTo = GetPlayerNameForIndex(toPlayerIdx); - locationCode = container[1].Value(); - itemCode = container[3].Value(); - } - - Log("{0} sent {1} {2} ({3})", userFrom, userTo, GetItemNameForID(itemCode), GetLocationNameForID(locationCode)); - - var notificationLevel = ApplicationSettings.Instance.MultiworldNotificationLevel; - if ((notificationLevel >= MultiworldNotificationLevel.Verbose && string.Equals(userFrom, mUserName)) || - (notificationLevel >= MultiworldNotificationLevel.Verbose && string.Equals(userTo, mUserName))) - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Message, string.Format("**{0}** sent **{1}** {2} *({3})*", userFrom, userTo, GetItemNameForID(itemCode), GetLocationNameForID(locationCode))); - - })); - } - } - catch - { - } - } - } - - private string GetPlayerNameForIndex(int idx) - { - string playerName; - if (!mTeamPlayerNameMap.TryGetValue(idx, out playerName)) - playerName = "Unknown Player"; - - return playerName; - } - - [Command("ReceivedItems")] - private void OnItemsReceived(JToken data) - { - lock (mReceivedItems) - { - JArray container = (JArray)data; - - int start_idx = container[0].Value(); - JArray items = container[1].Value(); - - if (start_idx == 0) - { - mReceivedItems.Clear(); - } - else if (start_idx != mReceivedItems.Count) - { - mReceivedItems.Clear(); - SendResync(); - return; - } - - if (start_idx == mReceivedItems.Count) - { - // Items are encoded as arrays for some reason :shrug: - foreach (JArray item in items) - { - ReceivedItem instance; - - if (SessionLoginType == SessionLoginType.Legacy) - { - instance = new ReceivedItem() - { - Item = item[0].Value(), - PlayerName = item[3].Value() - }; - - try - { - instance.Location = GetLocationNameForID(item[1].Value()); - } - catch - { - instance.Location = item[1].Value(); - } - } - else - { - instance = new ReceivedItem() - { - Item = item[0].Value(), - PlayerName = GetPlayerNameForIndex(item[2].Value()) - }; - - try - { - instance.Location = GetLocationNameForID(item[1].Value()); - } - catch - { - instance.Location = item[1].Value(); - } - } - - mReceivedItems.Add(instance); - } - } - } - } - - private bool WriteReceivedItems(IAddressableConnector connector, PackageManager.Game game) - { - const ulong RECV_PROGRESS_ADDR = 0x7ef4d0; - const ulong RECV_ITEM_ADDR = 0x7ef4d2; - - if (!IsInGame(connector)) - return true; - - try - { - lock (mReceivedItems) - { - I16BitConnector connector16 = (I16BitConnector)connector; - - ushort recv_idx = 0; - if (connector16.Read16(RECV_PROGRESS_ADDR, out recv_idx) && recv_idx < mReceivedItems.Count) - { - byte pendingItemCode = 0; - if (connector16.Read8(RECV_ITEM_ADDR, out pendingItemCode) && pendingItemCode == 0) - { - ReceivedItem item = mReceivedItems[(int)recv_idx]; - - if (ApplicationSettings.Instance.MultiworldNotificationLevel >= MultiworldNotificationLevel.Normal) - { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => - { - Log("Received {0} from {1} ({2})", GetItemNameForID(item.Item), item.PlayerName, item.Location); - ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Celebration, string.Format("Received **{0}** from **{1}** *({2})*", GetItemNameForID(item.Item), item.PlayerName, item.Location)); - - })); - } - - using (connector16.GetBatchContext16()) - { - connector16.Write16(RECV_PROGRESS_ADDR, ++recv_idx); - connector16.Write8(RECV_ITEM_ADDR, (byte)item.Item); - } - } - } - } - - return true; - } - catch - { - } - - return false; - } - - HashSet mCheckedLocations = new HashSet(); - - private void MemorySegment_OnMemorySegmentUpdated(MemorySegment segment, IAddressableConnector connector, PackageManager.Game game) - { - I8BitConnector connector8 = connector as I8BitConnector; - if (connector8 == null) - return; - - List newChecks = new List(); - - bool bHasCheckedInGameState = false; - foreach (LocationData location in Locations) - { - if (mCheckedLocations.Contains(location)) - continue; - - ulong locationAddress = 0x7ef000 + location.Offset; - - if (segment.ContainsAddress(locationAddress)) - { -#region -- Verify In-Game -- - if (!bHasCheckedInGameState) - { - if (!IsInGame(connector)) - return; - - bHasCheckedInGameState = true; - } - #endregion - - byte locationFlags = segment.ReadUInt8(locationAddress); - if ((locationFlags & location.Mask) != 0) - { - newChecks.Add(location); - mCheckedLocations.Add(location); - } - } - } - - SendLocationChecks(newChecks); - } - - private Message BuildLocationChecksMessage(IEnumerable locations) - { - if (locations == null) - return null; - - if (!locations.Any()) - return null; - - JArray locationIDArray = new JArray(); - foreach (LocationData location in locations) - { - locationIDArray.Add(JToken.FromObject(location.ID)); - } - - return new Message() - { - Command = "LocationChecks", - Data = locationIDArray - }; - } - - private void SendLocationChecks(IEnumerable locations) - { - SendMessages((x) => { }, BuildLocationChecksMessage(locations)); - } - - private void SendResync() - { - SendMessages((x) => { }, new Message() - { - Command = "Sync" - }, - BuildLocationChecksMessage(mCheckedLocations)); - } - - public void Say(string text) - { - if (!string.IsNullOrWhiteSpace(text)) - { - SendMessages((x) => { }, new Message() - { - Command = "Say", - Data = JToken.FromObject(text) - }); - } - } - -#region -- Location Data -- - - struct LocationData - { - public LocationData(string name, ulong offset, byte mask, int id) - { - Name = name; - Offset = offset; - Mask = mask; - ID = id; - } - - public string Name; - public ulong Offset; - public byte Mask; - public int ID; - } - - public string GetLocationNameForID(int id) - { - if (id == -100) - return "Cheat Console"; - - foreach (LocationData l in Locations) - { - if (l.ID == id) - return l.Name; - } - - return "Unknown Location"; - } - - static LocationData[] Locations = - { - new LocationData("Mushroom", 0x411, 0x10, 0x180013), - new LocationData("Bottle Merchant", 0x3c9, 0x2, 0x2eb18), - new LocationData("Flute Spot", 0x2aa, 0x40, 0x18014a), - new LocationData("Sunken Treasure", 0x2bb, 0x40, 0x180145), - new LocationData("Purple Chest", 0x3c9, 0x10, 0x33d68), - new LocationData("Blind's Hideout - Top", 0x23a, 0x10, 0xeb0f), - new LocationData("Blind's Hideout - Left", 0x23a, 0x20, 0xeb12), - new LocationData("Blind's Hideout - Right", 0x23a, 0x40, 0xeb15), - new LocationData("Blind's Hideout - Far Left", 0x23a, 0x80, 0xeb18), - new LocationData("Blind's Hideout - Far Right", 0x23b, 0x1, 0xeb1b), - new LocationData("Link's Uncle", 0x3c6, 0x1, 0x2df45), - new LocationData("Secret Passage", 0xaa, 0x10, 0xe971), - new LocationData("King Zora", 0x410, 0x2, 0xee1c3), - new LocationData("Zora's Ledge", 0x301, 0x40, 0x180149), - new LocationData("Waterfall Fairy - Left", 0x228, 0x10, 0xe9b0), - new LocationData("Waterfall Fairy - Right", 0x228, 0x20, 0xe9d1), - new LocationData("King's Tomb", 0x226, 0x10, 0xe97a), - new LocationData("Floodgate Chest", 0x216, 0x10, 0xe98c), - new LocationData("Link's House", 0x208, 0x10, 0xe9bc), - new LocationData("Kakariko Tavern", 0x206, 0x10, 0xe9ce), - new LocationData("Chicken House", 0x210, 0x10, 0xe9e9), - new LocationData("Aginah's Cave", 0x214, 0x10, 0xe9f2), - new LocationData("Sahasrahla's Hut - Left", 0x20a, 0x10, 0xea82), - new LocationData("Sahasrahla's Hut - Middle", 0x20a, 0x20, 0xea85), - new LocationData("Sahasrahla's Hut - Right", 0x20a, 0x40, 0xea88), - new LocationData("Sahasrahla", 0x410, 0x10, 0x2f1fc), - new LocationData("Kakariko Well - Top", 0x5e, 0x10, 0xea8e), - new LocationData("Kakariko Well - Left", 0x5e, 0x20, 0xea91), - new LocationData("Kakariko Well - Middle", 0x5e, 0x40, 0xea94), - new LocationData("Kakariko Well - Right", 0x5e, 0x80, 0xea97), - new LocationData("Kakariko Well - Bottom", 0x5f, 0x1, 0xea9a), - new LocationData("Blacksmith", 0x411, 0x4, 0x18002a), - new LocationData("Magic Bat", 0x411, 0x80, 0x180015), - new LocationData("Sick Kid", 0x410, 0x4, 0x339cf), - new LocationData("Hobo", 0x3c9, 0x1, 0x33e7d), - new LocationData("Lost Woods Hideout", 0x1c3, 0x2, 0x180000), - new LocationData("Lumberjack Tree", 0x1c5, 0x2, 0x180001), - new LocationData("Cave 45", 0x237, 0x4, 0x180003), - new LocationData("Graveyard Cave", 0x237, 0x2, 0x180004), - new LocationData("Checkerboard Cave", 0x24d, 0x2, 0x180005), - new LocationData("Mini Moldorm Cave - Far Left", 0x246, 0x10, 0xeb42), - new LocationData("Mini Moldorm Cave - Left", 0x246, 0x20, 0xeb45), - new LocationData("Mini Moldorm Cave - Right", 0x246, 0x40, 0xeb48), - new LocationData("Mini Moldorm Cave - Far Right", 0x246, 0x80, 0xeb4b), - new LocationData("Mini Moldorm Cave - Generous Guy", 0x247, 0x4, 0x180010), - new LocationData("Ice Rod Cave", 0x240, 0x10, 0xeb4e), - new LocationData("Bonk Rock Cave", 0x248, 0x10, 0xeb3f), - new LocationData("Library", 0x410, 0x80, 0x180012), - new LocationData("Potion Shop", 0x411, 0x20, 0x180014), - new LocationData("Lake Hylia Island", 0x2b5, 0x40, 0x180144), - new LocationData("Maze Race", 0x2a8, 0x40, 0x180142), - new LocationData("Desert Ledge", 0x2b0, 0x40, 0x180143), - new LocationData("Desert Palace - Big Chest", 0xe6, 0x10, 0xe98f), - new LocationData("Desert Palace - Torch", 0xe7, 0x4, 0x180160), - new LocationData("Desert Palace - Map Chest", 0xe8, 0x10, 0xe9b6), - new LocationData("Desert Palace - Compass Chest", 0x10a, 0x10, 0xe9cb), - new LocationData("Desert Palace - Big Key Chest", 0xea, 0x10, 0xe9c2), - new LocationData("Desert Palace - Boss", 0x67, 0x8, 0x180151), - new LocationData("Eastern Palace - Compass Chest", 0x150, 0x10, 0xe977), - new LocationData("Eastern Palace - Big Chest", 0x152, 0x10, 0xe97d), - new LocationData("Eastern Palace - Cannonball Chest", 0x172, 0x10, 0xe9b3), - new LocationData("Eastern Palace - Big Key Chest", 0x170, 0x10, 0xe9b9), - new LocationData("Eastern Palace - Map Chest", 0x154, 0x10, 0xe9f5), - new LocationData("Eastern Palace - Boss", 0x191, 0x8, 0x180150), - new LocationData("Master Sword Pedestal", 0x300, 0x40, 0x289b0), - new LocationData("Hyrule Castle - Boomerang Chest", 0xe2, 0x10, 0xe974), - new LocationData("Hyrule Castle - Map Chest", 0xe4, 0x10, 0xeb0c), - new LocationData("Hyrule Castle - Zelda's Chest", 0x100, 0x10, 0xeb09), - new LocationData("Sewers - Dark Cross", 0x64, 0x10, 0xe96e), - new LocationData("Sewers - Secret Room - Left", 0x22, 0x10, 0xeb5d), - new LocationData("Sewers - Secret Room - Middle", 0x22, 0x20, 0xeb60), - new LocationData("Sewers - Secret Room - Right", 0x22, 0x40, 0xeb63), - new LocationData("Sanctuary", 0x24, 0x10, 0xea79), - new LocationData("Castle Tower - Room 03", 0x1c0, 0x10, 0xeab5), - new LocationData("Castle Tower - Dark Maze", 0x1a0, 0x10, 0xeab2), - new LocationData("Old Man", 0x410, 0x1, 0xf69fa), - new LocationData("Spectacle Rock Cave", 0x1d5, 0x4, 0x180002), - new LocationData("Paradox Cave Lower - Far Left", 0x1de, 0x10, 0xeb2a), - new LocationData("Paradox Cave Lower - Left", 0x1de, 0x20, 0xeb2d), - new LocationData("Paradox Cave Lower - Right", 0x1de, 0x40, 0xeb30), - new LocationData("Paradox Cave Lower - Far Right", 0x1de, 0x80, 0xeb33), - new LocationData("Paradox Cave Lower - Middle", 0x1df, 0x1, 0xeb36), - new LocationData("Paradox Cave Upper - Left", 0x1fe, 0x10, 0xeb39), - new LocationData("Paradox Cave Upper - Right", 0x1fe, 0x20, 0xeb3c), - new LocationData("Spiral Cave", 0x1fc, 0x10, 0xe9bf), - new LocationData("Ether Tablet", 0x411, 0x1, 0x180016), - new LocationData("Spectacle Rock", 0x283, 0x40, 0x180140), - new LocationData("Tower of Hera - Basement Cage", 0x10f, 0x4, 0x180162), - new LocationData("Tower of Hera - Map Chest", 0xee, 0x10, 0xe9ad), - new LocationData("Tower of Hera - Big Key Chest", 0x10e, 0x10, 0xe9e6), - new LocationData("Tower of Hera - Compass Chest", 0x4e, 0x20, 0xe9fb), - new LocationData("Tower of Hera - Big Chest", 0x4e, 0x10, 0xe9f8), - new LocationData("Tower of Hera - Boss", 0xf, 0x8, 0x180152), - new LocationData("Pyramid", 0x2db, 0x40, 0x180147), - new LocationData("Catfish", 0x410, 0x20, 0xee185), - new LocationData("Stumpy", 0x410, 0x8, 0x330c7), - new LocationData("Digging Game", 0x2e8, 0x40, 0x180148), - new LocationData("Bombos Tablet", 0x411, 0x2, 0x180017), - new LocationData("Hype Cave - Top", 0x23c, 0x10, 0xeb1e), - new LocationData("Hype Cave - Middle Right", 0x23c, 0x20, 0xeb21), - new LocationData("Hype Cave - Middle Left", 0x23c, 0x40, 0xeb24), - new LocationData("Hype Cave - Bottom", 0x23c, 0x80, 0xeb27), - new LocationData("Hype Cave - Generous Guy", 0x23d, 0x4, 0x180011), - new LocationData("Peg Cave", 0x24f, 0x4, 0x180006), - new LocationData("Pyramid Fairy - Left", 0x22c, 0x10, 0xe980), - new LocationData("Pyramid Fairy - Right", 0x22c, 0x20, 0xe983), - new LocationData("Brewery", 0x20c, 0x10, 0xe9ec), - new LocationData("C-Shaped House", 0x238, 0x10, 0xe9ef), - new LocationData("Chest Game", 0x20d, 0x4, 0xeda8), - new LocationData("Bumper Cave Ledge", 0x2ca, 0x40, 0x180146), - new LocationData("Mire Shed - Left", 0x21a, 0x10, 0xea73), - new LocationData("Mire Shed - Right", 0x21a, 0x20, 0xea76), - new LocationData("Superbunny Cave - Top", 0x1f0, 0x10, 0xea7c), - new LocationData("Superbunny Cave - Bottom", 0x1f0, 0x20, 0xea7f), - new LocationData("Spike Cave", 0x22e, 0x10, 0xea8b), - new LocationData("Hookshot Cave - Top Right", 0x78, 0x10, 0xeb51), - new LocationData("Hookshot Cave - Top Left", 0x78, 0x20, 0xeb54), - new LocationData("Hookshot Cave - Bottom Right", 0x78, 0x80, 0xeb5a), - new LocationData("Hookshot Cave - Bottom Left", 0x78, 0x40, 0xeb57), - new LocationData("Floating Island", 0x285, 0x40, 0x180141), - new LocationData("Mimic Cave", 0x218, 0x10, 0xe9c5), - new LocationData("Swamp Palace - Entrance", 0x50, 0x10, 0xea9d), - new LocationData("Swamp Palace - Map Chest", 0x6e, 0x10, 0xe986), - new LocationData("Swamp Palace - Big Chest", 0x6c, 0x10, 0xe989), - new LocationData("Swamp Palace - Compass Chest", 0x8c, 0x10, 0xeaa0), - new LocationData("Swamp Palace - Big Key Chest", 0x6a, 0x10, 0xeaa6), - new LocationData("Swamp Palace - West Chest", 0x68, 0x10, 0xeaa3), - new LocationData("Swamp Palace - Flooded Room - Left", 0xec, 0x10, 0xeaa9), - new LocationData("Swamp Palace - Flooded Room - Right", 0xec, 0x20, 0xeaac), - new LocationData("Swamp Palace - Waterfall Room", 0xcc, 0x10, 0xeaaf), - new LocationData("Swamp Palace - Boss", 0xd, 0x8, 0x180154), - new LocationData("Thieves' Town - Big Key Chest", 0x1b6, 0x20, 0xea04), - new LocationData("Thieves' Town - Map Chest", 0x1b6, 0x10, 0xea01), - new LocationData("Thieves' Town - Compass Chest", 0x1b8, 0x10, 0xea07), - new LocationData("Thieves' Town - Ambush Chest", 0x196, 0x10, 0xea0a), - new LocationData("Thieves' Town - Attic", 0xca, 0x10, 0xea0d), - new LocationData("Thieves' Town - Big Chest", 0x88, 0x10, 0xea10), - new LocationData("Thieves' Town - Blind's Cell", 0x8a, 0x10, 0xea13), - new LocationData("Thieves' Town - Boss", 0x159, 0x8, 0x180156), - new LocationData("Skull Woods - Compass Chest", 0xce, 0x10, 0xe992), - new LocationData("Skull Woods - Map Chest", 0xb0, 0x20, 0xe99b), - new LocationData("Skull Woods - Big Chest", 0xb0, 0x10, 0xe998), - new LocationData("Skull Woods - Pot Prison", 0xae, 0x20, 0xe9a1), - new LocationData("Skull Woods - Pinball Room", 0xd0, 0x10, 0xe9c8), - new LocationData("Skull Woods - Big Key Chest", 0xae, 0x10, 0xe99e), - new LocationData("Skull Woods - Bridge Room", 0xb2, 0x10, 0xe9fe), - new LocationData("Skull Woods - Boss", 0x53, 0x8, 0x180155), - new LocationData("Ice Palace - Compass Chest", 0x5c, 0x10, 0xe9d4), - new LocationData("Ice Palace - Freezor Chest", 0xfc, 0x10, 0xe995), - new LocationData("Ice Palace - Big Chest", 0x13c, 0x10, 0xe9aa), - new LocationData("Ice Palace - Iced T Room", 0x15c, 0x10, 0xe9e3), - new LocationData("Ice Palace - Spike Room", 0xbe, 0x10, 0xe9e0), - new LocationData("Ice Palace - Big Key Chest", 0x3e, 0x10, 0xe9a4), - new LocationData("Ice Palace - Map Chest", 0x7e, 0x10, 0xe9dd), - new LocationData("Ice Palace - Boss", 0x1bd, 0x8, 0x180157), - new LocationData("Misery Mire - Big Chest", 0x186, 0x10, 0xea67), - new LocationData("Misery Mire - Map Chest", 0x186, 0x20, 0xea6a), - new LocationData("Misery Mire - Main Lobby", 0x184, 0x10, 0xea5e), - new LocationData("Misery Mire - Bridge Chest", 0x144, 0x10, 0xea61), - new LocationData("Misery Mire - Spike Chest", 0x166, 0x10, 0xe9da), - new LocationData("Misery Mire - Compass Chest", 0x182, 0x10, 0xea64), - new LocationData("Misery Mire - Big Key Chest", 0x1a2, 0x10, 0xea6d), - new LocationData("Misery Mire - Boss", 0x121, 0x8, 0x180158), - new LocationData("Turtle Rock - Compass Chest", 0x1ac, 0x10, 0xea22), - new LocationData("Turtle Rock - Roller Room - Left", 0x16e, 0x10, 0xea1c), - new LocationData("Turtle Rock - Roller Room - Right", 0x16e, 0x20, 0xea1f), - new LocationData("Turtle Rock - Chain Chomps", 0x16c, 0x10, 0xea16), - new LocationData("Turtle Rock - Big Key Chest", 0x28, 0x10, 0xea25), - new LocationData("Turtle Rock - Big Chest", 0x48, 0x10, 0xea19), - new LocationData("Turtle Rock - Crystaroller Room", 0x8, 0x10, 0xea34), - new LocationData("Turtle Rock - Eye Bridge - Bottom Left", 0x1aa, 0x80, 0xea31), - new LocationData("Turtle Rock - Eye Bridge - Bottom Right", 0x1aa, 0x40, 0xea2e), - new LocationData("Turtle Rock - Eye Bridge - Top Left", 0x1aa, 0x20, 0xea2b), - new LocationData("Turtle Rock - Eye Bridge - Top Right", 0x1aa, 0x10, 0xea28), - new LocationData("Turtle Rock - Boss", 0x149, 0x8, 0x180159), - new LocationData("Palace of Darkness - Shooter Room", 0x12, 0x10, 0xea5b), - new LocationData("Palace of Darkness - The Arena - Bridge", 0x54, 0x20, 0xea3d), - new LocationData("Palace of Darkness - Stalfos Basement", 0x14, 0x10, 0xea49), - new LocationData("Palace of Darkness - Big Key Chest", 0x74, 0x10, 0xea37), - new LocationData("Palace of Darkness - The Arena - Ledge", 0x54, 0x10, 0xea3a), - new LocationData("Palace of Darkness - Map Chest", 0x56, 0x10, 0xea52), - new LocationData("Palace of Darkness - Compass Chest", 0x34, 0x20, 0xea43), - new LocationData("Palace of Darkness - Dark Basement - Left", 0xd4, 0x10, 0xea4c), - new LocationData("Palace of Darkness - Dark Basement - Right", 0xd4, 0x20, 0xea4f), - new LocationData("Palace of Darkness - Dark Maze - Top", 0x32, 0x10, 0xea55), - new LocationData("Palace of Darkness - Dark Maze - Bottom", 0x32, 0x20, 0xea58), - new LocationData("Palace of Darkness - Big Chest", 0x34, 0x10, 0xea40), - new LocationData("Palace of Darkness - Harmless Hellway", 0x34, 0x40, 0xea46), - new LocationData("Palace of Darkness - Boss", 0xb5, 0x8, 0x180153), - new LocationData("Ganons Tower - Bob's Torch", 0x119, 0x4, 0x180161), - new LocationData("Ganons Tower - Hope Room - Left", 0x118, 0x20, 0xead9), - new LocationData("Ganons Tower - Hope Room - Right", 0x118, 0x40, 0xeadc), - new LocationData("Ganons Tower - Tile Room", 0x11a, 0x10, 0xeae2), - new LocationData("Ganons Tower - Compass Room - Top Left", 0x13a, 0x10, 0xeae5), - new LocationData("Ganons Tower - Compass Room - Top Right", 0x13a, 0x20, 0xeae8), - new LocationData("Ganons Tower - Compass Room - Bottom Left", 0x13a, 0x40, 0xeaeb), - new LocationData("Ganons Tower - Compass Room - Bottom Right", 0x13a, 0x80, 0xeaee), - new LocationData("Ganons Tower - DMs Room - Top Left", 0xf6, 0x10, 0xeab8), - new LocationData("Ganons Tower - DMs Room - Top Right", 0xf6, 0x20, 0xeabb), - new LocationData("Ganons Tower - DMs Room - Bottom Left", 0xf6, 0x40, 0xeabe), - new LocationData("Ganons Tower - DMs Room - Bottom Right", 0xf6, 0x80, 0xeac1), - new LocationData("Ganons Tower - Map Chest", 0x116, 0x10, 0xead3), - new LocationData("Ganons Tower - Firesnake Room", 0xfa, 0x10, 0xead0), - new LocationData("Ganons Tower - Randomizer Room - Top Left", 0xf8, 0x10, 0xeac4), - new LocationData("Ganons Tower - Randomizer Room - Top Right", 0xf8, 0x20, 0xeac7), - new LocationData("Ganons Tower - Randomizer Room - Bottom Left", 0xf8, 0x40, 0xeaca), - new LocationData("Ganons Tower - Randomizer Room - Bottom Right", 0xf8, 0x80, 0xeacd), - new LocationData("Ganons Tower - Bob's Chest", 0x118, 0x80, 0xeadf), - new LocationData("Ganons Tower - Big Chest", 0x118, 0x10, 0xead6), - new LocationData("Ganons Tower - Big Key Room - Left", 0x38, 0x20, 0xeaf4), - new LocationData("Ganons Tower - Big Key Room - Right", 0x38, 0x40, 0xeaf7), - new LocationData("Ganons Tower - Big Key Chest", 0x38, 0x10, 0xeaf1), - new LocationData("Ganons Tower - Mini Helmasaur Room - Left", 0x7a, 0x10, 0xeafd), - new LocationData("Ganons Tower - Mini Helmasaur Room - Right", 0x7a, 0x20, 0xeb00), - new LocationData("Ganons Tower - Pre-Moldorm Chest", 0x7a, 0x40, 0xeb03), - new LocationData("Ganons Tower - Validation Chest", 0x9a, 0x10, 0xeb06) - }; - -#endregion - -#region -- Item Descriptions -- - - struct ItemDescription - { - public ItemDescription(string name, int id) - { - Name = name; - ID = id; - } - - public string Name; - public int ID; - } - - public string GetItemNameForID(int id) - { - foreach (ItemDescription item in Items) - { - if (item.ID == id) - return item.Name; - } - - return "Unknown Item"; - } - - static ItemDescription[] Items = - { - new ItemDescription("Bow", 11), - new ItemDescription("Progressive Bow", 100), - new ItemDescription("Progressive Bow", 101), - new ItemDescription("Book of Mudora", 29), - new ItemDescription("Hammer", 9), - new ItemDescription("Hookshot", 10), - new ItemDescription("Magic Mirror", 26), - new ItemDescription("Ocarina", 20), - new ItemDescription("Pegasus Boots", 75), - new ItemDescription("Power Glove", 27), - new ItemDescription("Cape", 25), - new ItemDescription("Mushroom", 41), - new ItemDescription("Shovel", 19), - new ItemDescription("Lamp", 18), - new ItemDescription("Magic Powder", 13), - new ItemDescription("Moon Pearl", 31), - new ItemDescription("Cane of Somaria", 21), - new ItemDescription("Fire Rod", 7), - new ItemDescription("Flippers", 30), - new ItemDescription("Ice Rod", 8), - new ItemDescription("Titans Mitts", 28), - new ItemDescription("Ether", 16), - new ItemDescription("Bombos", 15), - new ItemDescription("Quake", 17), - new ItemDescription("Bottle", 22), - new ItemDescription("Bottle (Red Potion)", 43), - new ItemDescription("Bottle (Green Potion)", 44), - new ItemDescription("Bottle (Blue Potion)", 45), - new ItemDescription("Bottle (Fairy)", 61), - new ItemDescription("Bottle (Bee)", 60), - new ItemDescription("Bottle (Good Bee)", 72), - new ItemDescription("Master Sword", 80), - new ItemDescription("Tempered Sword", 2), - new ItemDescription("Fighter Sword", 73), - new ItemDescription("Golden Sword", 3), - new ItemDescription("Progressive Sword", 94), - new ItemDescription("Progressive Glove", 97), - new ItemDescription("Silver Arrows", 88), - new ItemDescription("Triforce", 106), - new ItemDescription("Power Star", 107), - new ItemDescription("Triforce Piece", 108), - new ItemDescription("Single Arrow", 67), - new ItemDescription("Arrows (10)", 68), - new ItemDescription("Arrow Upgrade (+10)", 84), - new ItemDescription("Arrow Upgrade (+5)", 83), - new ItemDescription("Single Bomb", 39), - new ItemDescription("Bombs (3)", 40), - new ItemDescription("Bombs (10)", 49), - new ItemDescription("Bomb Upgrade (+10)", 82), - new ItemDescription("Bomb Upgrade (+5)", 81), - new ItemDescription("Blue Mail", 34), - new ItemDescription("Red Mail", 35), - new ItemDescription("Progressive Armor", 96), - new ItemDescription("Blue Boomerang", 12), - new ItemDescription("Red Boomerang", 42), - new ItemDescription("Blue Shield", 4), - new ItemDescription("Red Shield", 5), - new ItemDescription("Mirror Shield", 6), - new ItemDescription("Progressive Shield", 95), - new ItemDescription("Bug Catching Net", 33), - new ItemDescription("Cane of Byrna", 24), - new ItemDescription("Boss Heart Container", 62), - new ItemDescription("Sanctuary Heart Container", 63), - new ItemDescription("Piece of Heart", 23), - new ItemDescription("Rupee (1)", 52), - new ItemDescription("Rupees (5)", 53), - new ItemDescription("Rupees (20)", 54), - new ItemDescription("Rupees (50)", 65), - new ItemDescription("Rupees (100)", 64), - new ItemDescription("Rupees (300)", 70), - new ItemDescription("Rupoor", 89), - new ItemDescription("Red Clock", 91), - new ItemDescription("Blue Clock", 92), - new ItemDescription("Green Clock", 93), - new ItemDescription("Single RNG", 98), - new ItemDescription("Multi RNG", 99), - new ItemDescription("Magic Upgrade (1/2)", 78), - new ItemDescription("Magic Upgrade (1/4)", 79), - new ItemDescription("Small Key (Eastern Palace)", 162), - new ItemDescription("Big Key (Eastern Palace)", 157), - new ItemDescription("Compass (Eastern Palace)", 141), - new ItemDescription("Map (Eastern Palace)", 125), - new ItemDescription("Small Key (Desert Palace)", 163), - new ItemDescription("Big Key (Desert Palace)", 156), - new ItemDescription("Compass (Desert Palace)", 140), - new ItemDescription("Map (Desert Palace)", 124), - new ItemDescription("Small Key (Tower of Hera)", 170), - new ItemDescription("Big Key (Tower of Hera)", 149), - new ItemDescription("Compass (Tower of Hera)", 133), - new ItemDescription("Map (Tower of Hera)", 117), - new ItemDescription("Small Key (Escape)", 160), - new ItemDescription("Big Key (Escape)", 159), - new ItemDescription("Compass (Escape)", 143), - new ItemDescription("Map (Escape)", 127), - new ItemDescription("Small Key (Agahnims Tower)", 164), - new ItemDescription("Small Key (Palace of Darkness)", 166), - new ItemDescription("Big Key (Palace of Darkness)", 153), - new ItemDescription("Compass (Palace of Darkness)", 137), - new ItemDescription("Map (Palace of Darkness)", 121), - new ItemDescription("Small Key (Thieves Town)", 171), - new ItemDescription("Big Key (Thieves Town)", 148), - new ItemDescription("Compass (Thieves Town)", 132), - new ItemDescription("Map (Thieves Town)", 116), - new ItemDescription("Small Key (Skull Woods)", 168), - new ItemDescription("Big Key (Skull Woods)", 151), - new ItemDescription("Compass (Skull Woods)", 135), - new ItemDescription("Map (Skull Woods)", 119), - new ItemDescription("Small Key (Swamp Palace)", 165), - new ItemDescription("Big Key (Swamp Palace)", 154), - new ItemDescription("Compass (Swamp Palace)", 138), - new ItemDescription("Map (Swamp Palace)", 122), - new ItemDescription("Small Key (Ice Palace)", 169), - new ItemDescription("Big Key (Ice Palace)", 150), - new ItemDescription("Compass (Ice Palace)", 134), - new ItemDescription("Map (Ice Palace)", 118), - new ItemDescription("Small Key (Misery Mire)", 167), - new ItemDescription("Big Key (Misery Mire)", 152), - new ItemDescription("Compass (Misery Mire)", 136), - new ItemDescription("Map (Misery Mire)", 120), - new ItemDescription("Small Key (Turtle Rock)", 172), - new ItemDescription("Big Key (Turtle Rock)", 147), - new ItemDescription("Compass (Turtle Rock)", 131), - new ItemDescription("Map (Turtle Rock)", 115), - new ItemDescription("Small Key (Ganons Tower)", 173), - new ItemDescription("Big Key (Ganons Tower)", 146), - new ItemDescription("Compass (Ganons Tower)", 130), - new ItemDescription("Map (Ganons Tower)", 114), - new ItemDescription("Small Key (Universal)", 175), - new ItemDescription("Nothing", 90), - new ItemDescription("Red Potion", 46), - new ItemDescription("Green Potion", 47), - new ItemDescription("Blue Potion", 48), - new ItemDescription("Bee", 14), - new ItemDescription("Small Heart", 66) - }; - -#endregion - - } -} diff --git a/EmoTracker/Extensions/BontaMultiworld/MultiWorldConnectionDialog.xaml.cs b/EmoTracker/Extensions/BontaMultiworld/MultiWorldConnectionDialog.xaml.cs deleted file mode 100644 index 3b91b21..0000000 --- a/EmoTracker/Extensions/BontaMultiworld/MultiWorldConnectionDialog.xaml.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Shapes; - -namespace EmoTracker.Extensions.BontaMultiworld -{ - /// - /// Interaction logic for MultiworldConnectionDialog.xaml - /// - public partial class MultiWorldConnectionDialog : Window - { - public MultiWorldConnectionDialog() - { - InitializeComponent(); - } - } -} diff --git a/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs b/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs deleted file mode 100644 index 0bd3c7a..0000000 --- a/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using ConnectorLib; -using EmoTracker.Core; -using EmoTracker.Core.Services; -using EmoTracker.Data.JSON; -using EmoTracker.Data.Packages; -using EmoTracker.Extensions.AutoTracker; -using EmoTracker.Services; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using WebSocketSharp; - -namespace EmoTracker.Extensions.BontaMultiworld -{ - public enum MultiWorldExtensionStatus - { - Unusable, - Usable, - Connected, - Error - } - - public class MultiWorldExtension : MultiWorldClientSession, Extension - { - #region -- Extension Metadata -- - - public string Name { get { return "BontaWorld"; } } - - public string UID { get { return "lttp_multiworld_bonta"; } } - - public int Priority { get { return -99; } } - - MultiWorldExtensionView mStatusIndicator; - - public FrameworkElement StatusBarControl - { - get - { - if (mStatusIndicator == null) - mStatusIndicator = new MultiWorldExtensionView() { DataContext = this }; - - return mStatusIndicator; - } - } - - #endregion - - - MultiWorldExtensionStatus mStatus = MultiWorldExtensionStatus.Unusable; - public MultiWorldExtensionStatus Status - { - get { return mStatus; } - private set { SetProperty(ref mStatus, value); } - } - - DelegateCommand mConnectCmd; - public DelegateCommand ConnectCmd - { - get { return mConnectCmd; } - private set { SetProperty(ref mConnectCmd, value); } - } - - DelegateCommand mDisconnectCmd; - public DelegateCommand DisconnectCmd - { - get { return mDisconnectCmd; } - private set { SetProperty(ref mDisconnectCmd, value); } - } - - DelegateCommand mJoinGameCmd; - public DelegateCommand JoinGameCmd - { - get { return mJoinGameCmd; } - private set { SetProperty(ref mJoinGameCmd, value); } - } - - DelegateCommand mClearMessageLogCmd; - public DelegateCommand ClearMessageLogCmd - { - get { return mClearMessageLogCmd; } - private set { SetProperty(ref mClearMessageLogCmd, value); } - } - - DelegateCommand mForfeitCmd; - public DelegateCommand ForfeitCmd - { - get { return mForfeitCmd; } - private set { SetProperty(ref mForfeitCmd, value); } - } - - DelegateCommand mPopOutCmd; - public DelegateCommand PopOutCmd - { - get { return mPopOutCmd; } - private set { SetProperty(ref mPopOutCmd, value); } - } - - public MultiWorldExtension() - { - ConnectCmd = new DelegateCommand(ConnectHandler, CanConnect); - DisconnectCmd = new DelegateCommand(Disconnect); - ForfeitCmd = new DelegateCommand(ForfeitHandler); - JoinGameCmd = new DelegateCommand(Authenticate, CanAuthenticate); - ClearMessageLogCmd = new DelegateCommand(ClearMessageLog); - PopOutCmd = new DelegateCommand(PopOutLogWindow, CanPopOutLogWindow); - } - - protected void RefreshCommandAvailability() - { - Application.Current.Dispatcher.BeginInvoke(new Action(() => - { - ConnectCmd?.RaiseCanExecuteChanged(); - DisconnectCmd?.RaiseCanExecuteChanged(); - JoinGameCmd?.RaiseCanExecuteChanged(); - ForfeitCmd?.RaiseCanExecuteChanged(); - PopOutCmd?.RaiseCanExecuteChanged(); - })); - } - - protected override void NotifyPropertyChanged([CallerMemberName] string propertyName = null) - { - RefreshCommandAvailability(); - base.NotifyPropertyChanged(propertyName); - } - - private void Autotracker_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) - { - RefreshCommandAvailability(); - } - - private void ConnectHandler(object obj) - { - Connect(); - } - - private void ForfeitHandler(object obj) - { - Say("!forfeit"); - } - - MultiWorldLogWindow mLogWindow; - public MultiWorldLogWindow LogWindow - { - get { return mLogWindow; } - set - { - if (SetProperty(ref mLogWindow, value) && mLogWindow != null) - { - mLogWindow.Closed += LogWindow_Closed; - } - } - } - - private void LogWindow_Closed(object sender, EventArgs e) - { - MultiWorldLogWindow typedSender = sender as MultiWorldLogWindow; - if (typedSender != null && typedSender == LogWindow) - LogWindow = null; - } - - private bool CanPopOutLogWindow(object obj) - { - return LogWindow == null; - } - - private void PopOutLogWindow(object obj) - { - LogWindow = new MultiWorldLogWindow() { DataContext = this }; - LogWindow.Show(); - } - - public void Start() - { - AutoTrackerExtension autotracker = ExtensionManager.Instance.FindExtension(); - if (autotracker != null) - autotracker.PropertyChanged += Autotracker_PropertyChanged; - } - - public void Stop() - { - } - - public void OnPackageLoaded() - { - } - - public void OnPackageUnloaded() - { - Disconnect(); - } - - public JToken SerializeToJson() - { - return null; - } - - public bool DeserializeFromJson(JToken token) - { - return true; - } - } -} diff --git a/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtensionView.xaml b/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtensionView.xaml deleted file mode 100644 index d10ee65..0000000 --- a/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtensionView.xaml +++ /dev/null @@ -1,323 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -