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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# WFInfo — Agent Guide

WPF (.NET Framework 4.8) desktop app for Warframe. OCR + market prices.

## Build & Run
- `dotnet build -c Release` (output: `bin/Release/net48/WFInfo.exe`)
- Launch with no args for normal UI mode
- Startup object is `WFInfo.CustomEntrypoint.Main()` (not `App.Main()`)

## Test Framework (headless OCR regression)
- Triggered automatically when any `.json` arg is passed: `WFInfo.exe map.json [output.json]`
- `cd tests && run_tests.bat` — locates built `WFInfo.exe` automatically
- Test data: `tests/data/<name>.json` + `<name>.png` pairs, listed in `tests/map.json`
- Exit codes: 0=all pass, 1=partial fail, 2=fatal error
- Real OCR pipeline (no mocks) — first run downloads market data from warframestat.us API

## Architecture
- **Entry** → `CustomEntrypoint.Main()` → Tesseract DLL bootstrap → `App.Main()` (WPF)
- **Dependency Injection** via `Microsoft.Extensions.DependencyInjection` in `Main.cs`
- **OCR** → `Ocr.cs`: screenshot → `ExtractPartBoxAutomatically` → Tesseract → Levenshtein `GetPartName()`
- **Data** → `Data.cs`: JSON from `api.warframestat.us/wfinfo/prices`, JWT auth, WebSocket for warframe.market
- **Auto-mode** → `LogCapture.cs`: reads Warframe `EE.log` via memory-mapped file, triggers on `"Got rewards"`
- **Screenshots** → dual backend: GDI (fallback) + Windows.Graphics.Capture (Win10+ 2004+)
- **Languages** → `LanguageProcessing/`: 11 processors (CJK, Cyrillic, Latin, Thai, Turkish, Polish)

## Key Tech Stack
- .NET Framework 4.8, WPF, WinForms interop
- Tesseract 5.2.0 (native DLLs via Costura.Fody bundling)
- Newtonsoft.Json, SharpDX.Direct3D11, AutoUpdater.NET

## Quirks & Gotchas
- `AllowUnsafeBlocks=true` — Tesseract interop
- Costura merges `Tesseract50.dll`, `leptonica-1.82.0.dll` into the exe
- Tesseract DLLs downloaded from GitHub `WFCD/WFinfo/libs` branch on first run to `%APPDATA%\WFInfo\tesseract5\`
- Release build auto-generates `update.xml` + `WFInfo.zip` via MSBuild targets
- Debug logs at `%APPDATA%\WFInfo\debug.log` (async queue, flushed every 250ms)
- DPI awareness: PerMonitorV2 (app.manifest)
- No CI workflows present
- Dependabot: NuGet weekly
154 changes: 149 additions & 5 deletions WFInfo/Data.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1073,11 +1073,15 @@ public string GetPartName(string name, out int low, bool suppressLogging, out bo

// Use English name (split[0]) for length comparison regardless of locale cache
int englishNameLength = split[0].Length;
int lengthDiff = Math.Abs((useLocalizedNames && split.Length >= 3 ? split[2].Length : split[0].Length) - name.Length);
if (lengthDiff > Math.Max(englishNameLength, name.Length) / 2) continue;

// Use localized name only if cache locale matches and available, otherwise fall back to English
string comparisonName = useLocalizedNames && split.Length >= 3 ? split[2] : split[0];

// Normalize stored name for length comparison (Korean and others strip whitespace)
string normalizedStoredName = processor.NormalizeForPatternMatching(comparisonName);
int lengthDiff = Math.Abs(normalizedStoredName.Length - normalizedName.Length);
if (lengthDiff > Math.Max(englishNameLength, normalizedName.Length) / 2) continue;

marketItemsSnapshot.Add(Tuple.Create(split[0], comparisonName));
}
}
Expand All @@ -1087,16 +1091,45 @@ public string GetPartName(string name, out int low, bool suppressLogging, out bo
}
}

// Add ignored items to the snapshot so they compete equally with market items
var ignoredItems = processor.IgnoredItemNames;
if (ignoredItems != null)
{
foreach (var kvp in ignoredItems)
{
// Normalize ignored item names to match normalized OCR input format
// e.g., "Чертёж: Форма" -> "форма (чертеж)" for proper matching
string normalizedIgnoredName = processor.NormalizeForPatternMatching(kvp.Value);
marketItemsSnapshot.Add(Tuple.Create(kvp.Key, normalizedIgnoredName));
}
}

// Do heavy Levenshtein work outside lock
foreach (var item in marketItemsSnapshot)
{
string englishName = item.Item1;
string storedName = item.Item2;

int val = processor.CalculateLevenshteinDistance(name, storedName);
// Normalize stored name to match OCR input format for fair comparison
string normalizedStoredName = processor.NormalizeForPatternMatching(storedName);

// For Korean: use full CalculateLevenshteinDistance with Jamo-aware similarity
// to handle severely mangled OCR (e.g., "뉴모 오티스 섬" vs "뉴로옵틱스")
int val;
if (processor.Locale == "ko")
{
// CalculateLevenshteinDistance normalizes internally, pass raw strings
val = processor.CalculateLevenshteinDistance(name, storedName);
}
else
{
// Use SimpleLevenshteinDistance to avoid aggressive preprocessing
// that removes blueprint terms (prevents "чертёж" removal causing false matches)
val = processor.SimpleLevenshteinDistance(normalizedName, normalizedStoredName);
}

// Distance filter: Only accept matches with distance < 50% of string length (like GetLocalizedNameData)
if (val >= storedName.Length * 0.5) continue;
// Distance filter: Use language-specific threshold (default 50%, Korean 60% for garbled OCR)
if (val >= normalizedStoredName.Length * processor.DistanceThresholdRatio) continue;

if (val < low)
{
Expand Down Expand Up @@ -1156,6 +1189,117 @@ public string GetPartName(string name, out int low, bool suppressLogging, out bo
return lowest;
}

/// <summary>
/// Gets the localized name for an English part name from market items.
/// Returns the English name if no localized version is available.
/// </summary>
public string GetLocalizedNameForClipboard(string englishName)
{
if (_settings.Locale == "en" || string.IsNullOrEmpty(englishName))
return englishName;

lock (marketItemsLock)
{
if (marketItems == null)
return englishName;

// Check if cached locale matches current locale
string cachedLocale = marketItems.TryGetValue("locale", out var localeToken) ? localeToken?.ToString() : null;
if (cachedLocale != _settings.Locale)
return englishName; // Fall back to English if locale doesn't match

foreach (var marketItem in marketItems)
{
if (marketItem.Key == "locale" || marketItem.Key == "version")
continue;

string[] split = marketItem.Value.ToString().Split('|');
if (split.Length < 3)
continue;

// Compare against English name (split[0])
if (split[0].Equals(englishName, StringComparison.OrdinalIgnoreCase))
{
// Return localized name (split[2]) if available and not empty
string localizedName = split[2];
if (!string.IsNullOrEmpty(localizedName))
return localizedName;
break;
}
}
}

return englishName; // Fallback to English if not found
}

/// <summary>
/// Removes blueprint terms from a localized part name for clipboard display.
/// Uses the current language processor's BlueprintRemovals list.
/// </summary>
public string RemoveBlueprintTerms(string localizedName)
{
if (string.IsNullOrEmpty(localizedName))
return localizedName;

var processor = LanguageProcessorFactory.GetCurrentProcessor();
string result = localizedName;

// Get blueprint removal terms for current language
var blueprintTerms = processor.BlueprintRemovals;
if (blueprintTerms != null)
{
foreach (var term in blueprintTerms)
{
if (string.IsNullOrEmpty(term))
continue;

string escapedTerm = Regex.Escape(term);

// Remove " Term" or " Term " patterns (case insensitive)
result = Regex.Replace(result, $"\\s+{escapedTerm}\\s*$", "", RegexOptions.IgnoreCase);
result = Regex.Replace(result, $"\\s+{escapedTerm}\\s+", " ", RegexOptions.IgnoreCase);

// Remove term preceded by common punctuation: " - Term", " – Term", " — Term", ": Term"
result = Regex.Replace(result, $"[:\\-–—]\\s*{escapedTerm}\\s*$", "", RegexOptions.IgnoreCase);
result = Regex.Replace(result, $"[:\\-–—]\\s*{escapedTerm}\\s+", " ", RegexOptions.IgnoreCase);

// Remove term followed by common punctuation: "Term - ", "Term:"
result = Regex.Replace(result, $"\\s*{escapedTerm}\\s*[:\\-–—]", "", RegexOptions.IgnoreCase);

// Remove term at boundaries (standalone)
result = Regex.Replace(result, $"\\b{escapedTerm}\\b", "", RegexOptions.IgnoreCase);
}
}

// Always strip English "Blueprint" regardless of locale
result = Regex.Replace(result, "\\s*Blueprint\\s*$", "", RegexOptions.IgnoreCase);
result = Regex.Replace(result, "\\s*Blueprint\\s+", " ", RegexOptions.IgnoreCase);
result = Regex.Replace(result, "^Blueprint\\s*[:\\-–—]?\\s*", "", RegexOptions.IgnoreCase);

// Special handling for Russian "Чертёж:" prefix format
if (_settings.Locale == "ru")
{
result = Regex.Replace(result, "^Черт[её]ж:\\s*", "", RegexOptions.IgnoreCase);
result = Regex.Replace(result, "^черт[её]ж:\\s*", "", RegexOptions.IgnoreCase);
}

return result.Trim();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// <summary>
/// Checks if a part name is an ignored item (0 plat, 0 ducats).
/// Uses the current language processor's cached HashSet for O(1) lookup.
/// Works with both English and localized names.
/// </summary>
public bool IsIgnoredItem(string partName)
{
if (string.IsNullOrEmpty(partName))
return false;

var processor = LanguageProcessorFactory.GetCurrentProcessor();
return processor.IsIgnoredItem(partName);
}

public string GetPartNameHuman(string name, out int low)
{ // Checks the Levenshtein Distance of a string and returns the index in Names() of the closest part optimized for human searching
string lowest = null;
Expand Down
10 changes: 10 additions & 0 deletions WFInfo/FodyWeavers.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimes" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtimes to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimes" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtimes names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
Expand Down
35 changes: 35 additions & 0 deletions WFInfo/LanguageProcessing/ChineseLanguageProcessor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using WFInfo.Settings;

Expand Down Expand Up @@ -118,6 +119,23 @@ public SimplifiedChineseLanguageProcessor(IReadOnlyApplicationSettings settings)

public override string[] BlueprintRemovals => new[] { "蓝图", "设计图" };

private static readonly IReadOnlyDictionary<string, string> _ignoredItemNames = new Dictionary<string, string>
{
["Forma Blueprint"] = "Forma 蓝图",
["Exilus Weapon Adapter Blueprint"] = "Exilus 武器适配器 蓝图",
["Kuva"] = "赤毒",
["Riven Sliver"] = "裂罅碎块",
["Ayatan Amber Star"] = "阿耶檀识 琥珀星",
["Galariak Prime Blueprint"] = "加拉瑞克 Prime 蓝图",
["Galariak Prime Blade"] = "加拉瑞克 Prime 刀刃 蓝图",
["Galariak Prime Handle"] = "加拉瑞克 Prime 握柄 蓝图",
["Sagek Prime Blueprint"] = "萨杰克 Prime 蓝图",
["Sagek Prime Barrel"] = "萨杰克 Prime 枪管 蓝图",
["Sagek Prime Receiver"] = "萨杰克 Prime 枪机 蓝图"
};

public override IReadOnlyDictionary<string, string> IgnoredItemNames => _ignoredItemNames;

public override int CalculateLevenshteinDistance(string s, string t)
{
return LevenshteinDistanceWithPreprocessing(s, t, BlueprintRemovals, NormalizeChineseCharacters, callBaseDefault: true);
Expand All @@ -138,6 +156,23 @@ public TraditionalChineseLanguageProcessor(IReadOnlyApplicationSettings settings

public override string[] BlueprintRemovals => new[] { "藍圖", "設計圖" };

private static readonly IReadOnlyDictionary<string, string> _ignoredItemNames = new Dictionary<string, string>
{
["Forma Blueprint"] = "Forma 藍圖",
["Exilus Weapon Adapter Blueprint"] = "Exilus 武器適配器 藍圖",
["Kuva"] = "赤毒",
["Riven Sliver"] = "裂罅碎塊",
["Ayatan Amber Star"] = "阿耶檀識 琥珀星",
["Galariak Prime Blueprint"] = "加拉瑞克 Prime 藍圖",
["Galariak Prime Blade"] = "加拉瑞克 Prime 刀刃 藍圖",
["Galariak Prime Handle"] = "加拉瑞克 Prime 握柄 藍圖",
["Sagek Prime Blueprint"] = "薩傑克 Prime 藍圖",
["Sagek Prime Barrel"] = "薩傑克 Prime 槍管 藍圖",
["Sagek Prime Receiver"] = "薩傑克 Prime 槍機 藍圖"
};

public override IReadOnlyDictionary<string, string> IgnoredItemNames => _ignoredItemNames;

public override int CalculateLevenshteinDistance(string s, string t)
{
return LevenshteinDistanceWithPreprocessing(s, t, BlueprintRemovals, NormalizeChineseCharacters, callBaseDefault: true);
Expand Down
Loading