Merge remote-tracking branch 'origin/dev' into feature/new-viewer

# Conflicts:
#	CUE4Parse
#	FModel/MainWindow.xaml
#	FModel/ViewModels/CUE4ParseViewModel.cs
#	FModel/Views/Snooper/Animations/Skeleton.cs
#	FModel/Views/Snooper/Models/SkeletalModel.cs
#	FModel/Views/Snooper/Models/StaticModel.cs
#	FModel/Views/Snooper/Renderer.cs
This commit is contained in:
Asval
2026-09-16 15:58:40 +02:00
22 changed files with 407 additions and 80 deletions

View File

@@ -32,6 +32,7 @@ public class BaseIcon : UCreator
// rarity
if (Object.TryGetValue(out FPackageIndex series, "Series")) GetSeries(series);
else if (Object.TryGetValue(out FStructFallback componentContainer, "ComponentContainer")) GetSeries(componentContainer);
else if (Object.TryGetValue(out EFortRarity hackRarity, "HackRarity")) GetRarity(hackRarity);
else GetRarity(Object.GetOrDefault("Rarity", EFortRarity.Uncommon)); // default is uncommon
if (Object.TryGetValue(out FInstancedStruct[] dataList, "DataList"))
@@ -48,7 +49,7 @@ public class BaseIcon : UCreator
Preview = preview;
else if (Object.TryGetValue(out FPackageIndex itemDefinition, "HeroDefinition", "WeaponDefinition"))
Preview = Utils.GetBitmap(itemDefinition);
else if (Object.TryGetValue(out FSoftObjectPath largePreview, "LargePreviewImage", "EntryListIcon", "SmallPreviewImage", "BundleImage", "ItemDisplayAsset", "LargeIcon", "ToastIcon", "SmallIcon"))
else if (Object.TryGetValue(out FSoftObjectPath largePreview, "LargePreviewImage", "EntryListIcon", "SmallPreviewImage", "BundleImage", "ItemDisplayAsset", "LargeIcon", "ToastIcon", "SmallIcon", "CartridgeIcon", "EffectIcon"))
Preview = Utils.GetBitmap(largePreview);
else if (Object.TryGetValue(out string s, "LargePreviewImage") && !string.IsNullOrEmpty(s))
Preview = Utils.GetBitmap(s);
@@ -66,9 +67,9 @@ public class BaseIcon : UCreator
}
// text
if (Object.TryGetValue(out FText displayName, "DisplayName", "ItemName", "BundleName", "DefaultHeaderText", "UIDisplayName", "EntryName", "EventCalloutTitle"))
if (Object.TryGetValue(out FText displayName, "DisplayName", "ItemName", "BundleName", "DefaultHeaderText", "UIDisplayName", "EntryName", "EventCalloutTitle", "KeyName", "HackDisplayName"))
DisplayName = displayName.Text;
if (Object.TryGetValue(out FText description, "Description", "ItemDescription", "SetDescription", "BundleDescription", "GeneralDescription", "DefaultBodyText", "UIDescription", "UIDisplayDescription", "EntryDescription", "EventCalloutDescription"))
if (Object.TryGetValue(out FText description, "Description", "ItemDescription", "SetDescription", "BundleDescription", "GeneralDescription", "DefaultBodyText", "UIDescription", "UIDisplayDescription", "EntryDescription", "EventCalloutDescription", "KeyDescription", "HackDescription"))
Description = description.Text;
else if (Object.TryGetValue(out FText[] descriptions, "Description"))
Description = string.Join('\n', descriptions.Select(x => x.Text));

View File

@@ -65,6 +65,7 @@ public class CreatorPackage : IDisposable
case "AthenaEmojiItemDefinition":
case "AthenaItemWrapDefinition":
case "AthenaToyItemDefinition":
case "FortHackData":
case "FortHeroType":
case "FortTokenType":
case "FortAbilityKit":
@@ -72,7 +73,9 @@ public class CreatorPackage : IDisposable
case "FortMissionInfo":
case "RewardGraphToken":
case "JunoKnowledgeBundle":
case "FortHackDataMinimal":
case "FortBannerTokenType":
case "FortHackKeyCoreData":
case "FortVariantTokenType":
case "FortDecoItemDefinition":
case "FortStatItemDefinition":
@@ -88,6 +91,7 @@ public class CreatorPackage : IDisposable
case "SparksBassItemDefinition":
case "FortGadgetItemDefinition":
case "AthenaCharmItemDefinition":
case "FortHackKeyItemDefinition":
case "FortPlaysetItemDefinition":
case "FortGiftBoxItemDefinition":
case "FortOutpostItemDefinition":
@@ -124,6 +128,7 @@ public class CreatorPackage : IDisposable
case "JunoRecipeBundleItemDefinition":
case "FortHomebaseNodeItemDefinition":
case "FortNeverPersistItemDefinition":
case "FortHackCartridgeItemDefinition":
case "FortPlayerAugmentItemDefinition":
case "FortSmartBuildingItemDefinition":
case "FortGiftBoxUnlockItemDefinition":

View File

@@ -131,6 +131,7 @@ public enum EAssetCategory : uint
CustomizableObject = Mesh + 3,
NaniteDisplacedMesh = Mesh + 4,
GeometryCollection = Mesh + 5,
ChaosClothAsset = Mesh + 6,
Texture = AssetCategoryExtensions.CategoryBase + (3 << 16),
Materials = AssetCategoryExtensions.CategoryBase + (4 << 16),
Material = Materials + 1,
@@ -167,6 +168,15 @@ public enum EAssetCategory : uint
DeltaForce = GameSpecific + 4,
LegoBatman = GameSpecific + 5,
ArcSys = GameSpecific + 6,
GothamKnights = GameSpecific + 7,
}
public enum EAssetFamily
{
None,
Criware,
Wwise,
FMod,
}
public enum EUnluacMode

View File

@@ -11,7 +11,9 @@ public static class VisualTreeExtensions
{
if (current is T t)
return t;
current = VisualTreeHelper.GetParent(current);
current = current is FrameworkContentElement contentElement
? contentElement.Parent
: VisualTreeHelper.GetParent(current);
}
return null;
}

View File

@@ -820,6 +820,33 @@
<TextBlock VerticalAlignment="Center" Text="{Binding PendingRequest.DisplayText, Source={x:Static viewModels:SnooperViewModel.Instance}, Mode=OneWay, StringFormat={}{0}: double-click one}" />
</StatusBarItem>
<StatusBarItem Margin="0 0 10 0"
Padding="0"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
<Button HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Center"
Style="{StaticResource StatusBarButton}"
Background="{DynamicResource {x:Static adonisUi:Brushes.AlertBrush}}"
ToolTip="A new version of FModel is available. Click to view it."
Command="{Binding MenuCommand}"
CommandParameter="Help_Releases">
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center">
<Viewbox Width="16"
Height="16">
<Canvas Width="24"
Height="24">
<Path Fill="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
Data="{StaticResource UpdateIcon}" />
</Canvas>
</Viewbox>
<TextBlock Margin="10 0 0 0"
Text="Update Available" />
</StackPanel>
</Button>
</StatusBarItem>
<StatusBarItem Margin="0 0 10 0" Padding="0" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
<Button x:Name="ExportSessionButton"
HorizontalContentAlignment="Stretch"

View File

@@ -131,6 +131,7 @@ public class FModelApiEndpoint : AbstractApiProvider
UserSettings.Default.LastUpdateCheck = DateTime.Now;
var targetHash = ((CustomMandatory) args.Mandatory).CommitHash;
_applicationView.IsUpdateAvailable = targetHash != Constants.APP_COMMIT_ID;
if (targetHash == Constants.APP_COMMIT_ID)
{
if (UserSettings.Default.ShowChangelog)

View File

@@ -61,6 +61,13 @@ public class ApplicationViewModel : ViewModel
}
}
private bool _isUpdateAvailable;
public bool IsUpdateAvailable
{
get => _isUpdateAvailable;
internal set => SetProperty(ref _isUpdateAvailable, value);
}
private int _selectedLeftTabIndex;
public int SelectedLeftTabIndex
{
@@ -139,7 +146,7 @@ public class ApplicationViewModel : ViewModel
public DirectorySettings AvoidEmptyGameDirectory(bool bAlreadyLaunched)
{
var gameDirectory = UserSettings.Default.GameDirectory;
if (!bAlreadyLaunched && UserSettings.Default.PerDirectory.TryGetValue(gameDirectory, out var currentDir))
if (!bAlreadyLaunched && GameSelectorViewModel.IsGameDirectoryAvailable(gameDirectory) && UserSettings.Default.PerDirectory.TryGetValue(gameDirectory, out var currentDir))
return currentDir;
Status.SetStatus(EStatusKind.Configuring);

View File

@@ -14,9 +14,9 @@ using CSCore.CoreAudioAPI;
using CSCore.DSP;
using CSCore.SoundOut;
using CSCore.Streams;
using CUE4Parse.UE4.CriWare.Decoders;
using CUE4Parse.UE4.CriWare.Decoders.ADX;
using CUE4Parse.UE4.CriWare.Decoders.HCA;
using CUE4Parse.UE4.Criware.Decoders;
using CUE4Parse.UE4.Criware.Decoders.ADX;
using CUE4Parse.UE4.Criware.Decoders.HCA;
using CUE4Parse.Utils;
using FModel.Extensions;
using FModel.Framework;

View File

@@ -10,6 +10,8 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AdonisUI.Controls;
using CUE4Parse_Conversion.Exporters;
using CUE4Parse_Conversion.Sounds;
using CUE4Parse;
using CUE4Parse.Compression;
using CUE4Parse.Encryption.Aes;
@@ -22,15 +24,17 @@ using CUE4Parse.GameTypes.AshEchoes.FileProvider;
using CUE4Parse.GameTypes.Borderlands3.Assets.Exports;
using CUE4Parse.GameTypes.Borderlands4.Assets.Exports;
using CUE4Parse.GameTypes.Borderlands4.Wwise;
using CUE4Parse.GameTypes.Dawnwalker.Assets.Exports;
using CUE4Parse.GameTypes.DFHO.Assets.Objects;
using CUE4Parse.GameTypes.HonorOfKings.FileProvider;
using CUE4Parse.GameTypes.KRD.Assets.Exports;
using CUE4Parse.GameTypes.LegoBatman.Assets;
using CUE4Parse.GameTypes.LordOfMysteries.FileProvider;
using CUE4Parse.GameTypes.RocoKingdomWorld.Assets.Objects;
using CUE4Parse.GameTypes.Tencent.RocoKingdomWorld.Assets.Objects;
using CUE4Parse.GameTypes.SMG.UE4.Assets.Exports.Wwise;
using CUE4Parse.GameTypes.SquareEnix.UE4.Assets.Exports;
using CUE4Parse.GameTypes.Theia.FileProvider;
using CUE4Parse.GameTypes.WarnerBros.GothamKnights.Assets.Exports.Wwise;
using CUE4Parse.MappingsProvider;
using CUE4Parse.MappingsProvider.Jmap;
using CUE4Parse.MappingsProvider.Usmap;
@@ -38,10 +42,11 @@ using CUE4Parse.UE4.AssetRegistry;
using CUE4Parse.UE4.Assets;
using CUE4Parse.UE4.Assets.Exports;
using CUE4Parse.UE4.Assets.Exports.Animation;
using CUE4Parse.UE4.Assets.Exports.CriWare;
using CUE4Parse.UE4.Assets.Exports.Criware;
using CUE4Parse.UE4.Assets.Exports.Engine;
using CUE4Parse.UE4.Assets.Exports.Fmod;
using CUE4Parse.UE4.Assets.Exports.GeometryCollection;
using CUE4Parse.UE4.Assets.Exports.Material;
using CUE4Parse.UE4.Assets.Exports.SkeletalMesh;
using CUE4Parse.UE4.Assets.Exports.Sound;
using CUE4Parse.UE4.Assets.Exports.StaticMesh;
using CUE4Parse.UE4.Assets.Exports.Texture;
@@ -49,8 +54,8 @@ using CUE4Parse.UE4.Assets.Exports.Verse;
using CUE4Parse.UE4.Assets.Exports.Wwise;
using CUE4Parse.UE4.Assets.Objects;
using CUE4Parse.UE4.BinaryConfig;
using CUE4Parse.UE4.CriWare;
using CUE4Parse.UE4.CriWare.Readers;
using CUE4Parse.UE4.Criware;
using CUE4Parse.UE4.Criware.Readers;
using CUE4Parse.UE4.FMod;
using CUE4Parse.UE4.GameFeatures;
using CUE4Parse.UE4.IO;
@@ -1300,6 +1305,11 @@ public class CUE4ParseViewModel : ViewModel
TabControl.SelectedTab.AddImage(sourceFile.SubstringAfterLast('/'), false, bitmap, false, updateUi);
return false;
}
case UBiomesMaskTextureData when (isNone || saveTextures) && pointer.Object.Value is UBiomesMaskTextureData biomes && biomes.MaskData is { } maskData:
{
TabControl.SelectedTab.AddImage(biomes.Name, false, SKBitmap.Decode(maskData.Value), false, updateUi);
return false;
}
// Supermassive Games (for example - The Dark Pictures Anthology: House of Ashes etc.)
case UExternalSource when (isNone || saveAudio) && pointer.Object.Value is UExternalSource externalSource:
{
@@ -1506,12 +1516,37 @@ public class CUE4ParseViewModel : ViewModel
}
return false;
}
// Gotham Knights
case UOrpheusBank when (isNone || saveAudio) && pointer.Object.Value is UOrpheusBank orpheusBank:
{
if (orpheusBank.SoundBank is null)
return false;
var extractedSounds = WwiseProvider.ExtractBankSounds(orpheusBank.SoundBank);
foreach (var sound in extractedSounds)
{
SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi);
}
return false;
}
// Gotham Knights
case UOrpheusEvent when (isNone || saveAudio) && pointer.Object.Value is UOrpheusEvent orpheusEvent:
{
var extractedSounds = WwiseProvider.ExtractGothamKnightsAudioEventSounds(orpheusEvent);
foreach (var sound in extractedSounds)
{
SaveAndPlaySound(cancellationToken, sound.OutputPath, sound.Extension, sound.Data?.GetData() ?? [], saveAudio, updateUi);
}
return false;
}
case UWorld when isNone && UserSettings.Default.PreviewWorlds:
case UBlueprintGeneratedClass when isNone && UserSettings.Default.PreviewWorlds:
// case UPaperSprite when isNone && UserSettings.Default.PreviewMaterials:
case UStaticMesh when isNone && UserSettings.Default.PreviewStaticMeshes:
case UGeometryCollection when isNone && UserSettings.Default.PreviewStaticMeshes:
case USkeletalMesh when isNone && UserSettings.Default.PreviewSkeletalMeshes:
case USkinnedAsset when isNone && UserSettings.Default.PreviewSkeletalMeshes:
case USkeleton when isNone && UserSettings.Default.SaveSkeletonAsMesh:
case UAnimationAsset when isNone && UserSettings.Default.PreviewAnimations:
// case UMaterialInstance when isNone && UserSettings.Default.PreviewMaterials &&
@@ -1524,7 +1559,8 @@ public class CUE4ParseViewModel : ViewModel
return true;
}
case UStaticMesh when HasFlag(bulk, EBulkType.Meshes):
case USkeletalMesh when HasFlag(bulk, EBulkType.Meshes):
case UGeometryCollection when HasFlag(bulk, EBulkType.Meshes):
case USkinnedAsset when HasFlag(bulk, EBulkType.Meshes):
case USkeleton when UserSettings.Default.SaveSkeletonAsMesh && HasFlag(bulk, EBulkType.Meshes):
// case UMaterialInterface when HasFlag(bulk, EBulkType.Materials):
case UAnimationAsset when HasFlag(bulk, EBulkType.Animations):

View File

@@ -148,7 +148,7 @@ public class LoadCommand : ViewModelCommand<LoadingModesViewModel>
{
entries.Add(asset);
}
else if (includeLooseFiles && asset is OsGameFile)
else if (includeLooseFiles && asset is not VfsEntry)
{
entries.Add(asset);
}

View File

@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using CUE4Parse_Conversion.Textures;
using CUE4Parse.FileProvider.Objects;
using CUE4Parse.GameTypes.Borderlands3.Assets.Exports;
using CUE4Parse.GameTypes.Borderlands4.Assets.Exports;
@@ -15,12 +15,15 @@ using CUE4Parse.GameTypes.RED.Assets.Exports;
using CUE4Parse.GameTypes.SMG.UE4.Assets.Exports.Wwise;
using CUE4Parse.GameTypes.SMG.UE4.Assets.Objects;
using CUE4Parse.GameTypes.SquareEnix.UE4.Assets.Exports;
using CUE4Parse.GameTypes.WarnerBros.GothamKnights.Assets.Exports.Wwise;
using CUE4Parse.UE4.Assets;
using CUE4Parse.UE4.Assets.Exports;
using CUE4Parse.UE4.Assets.Exports.Animation;
using CUE4Parse.UE4.Assets.Exports.BuildData;
using CUE4Parse.UE4.Assets.Exports.ChaosClothAsset;
using CUE4Parse.UE4.Assets.Exports.Component;
using CUE4Parse.UE4.Assets.Exports.CriWare;
using CUE4Parse.UE4.Assets.Exports.Criware;
using CUE4Parse.UE4.Assets.Exports.Criware.Media;
using CUE4Parse.UE4.Assets.Exports.CustomizableObject;
using CUE4Parse.UE4.Assets.Exports.Engine;
using CUE4Parse.UE4.Assets.Exports.Engine.Font;
@@ -51,17 +54,11 @@ using CUE4Parse.UE4.Objects.UObject;
using CUE4Parse.UE4.Objects.UObject.Editor;
using CUE4Parse.UE4.Versions;
using CUE4Parse.Utils;
using CUE4Parse_Conversion.Textures;
using FModel.Framework;
using FModel.Services;
using FModel.Settings;
using Serilog;
using SkiaSharp;
using Svg.Skia;
namespace FModel.ViewModels;
@@ -101,6 +98,13 @@ public class GameFileViewModel(GameFile asset) : ViewModel
}
}
private EAssetFamily _assetFamily = EAssetFamily.None;
public EAssetFamily AssetFamily
{
get => _assetFamily;
private set => SetProperty(ref _assetFamily, value);
}
private EBulkType _assetActions = EBulkType.None;
public EBulkType AssetActions
{
@@ -204,6 +208,20 @@ public class GameFileViewModel(GameFile asset) : ViewModel
var dummy = ((AbstractUePackage) pkg).ConstructObject(pointer.Class, pkg);
ResolvedAssetType = dummy.ExportType;
AssetFamily = dummy switch
{
UAtomSoundBase or UAtomSoundBank or USoundAtomCue or USoundAtomCueSheet
or USoundAtomConfig or UAtomDspBusSetting or UAtomRackBase => EAssetFamily.Criware,
UAkAudioType or UAkAssetData or UAkAssetPlatformData or UAkMediaAssetData
or UAkMediaAsset or UWwiseAssetLibrary or UExternalSource or UExternalSourceBank => EAssetFamily.Wwise,
UFMODEvent or UFMODBank or UFMODBankLookup or UFMODBus
or UFMODSnapshot or UFMODSnapshotReverb or UFMODVCA => EAssetFamily.FMod,
_ => EAssetFamily.None,
};
(AssetCategory, AssetActions) = dummy switch
{
URigVMBlueprintGeneratedClass => (EAssetCategory.RigVMBlueprintGeneratedClass, EBulkType.Code),
@@ -217,9 +235,10 @@ public class GameFileViewModel(GameFile asset) : ViewModel
UStaticMesh => (EAssetCategory.StaticMesh, EBulkType.Meshes),
USkeletalMesh => (EAssetCategory.SkeletalMesh, EBulkType.Meshes),
UChaosClothAsset => (EAssetCategory.ChaosClothAsset, EBulkType.Meshes),
UCustomizableObject => (EAssetCategory.CustomizableObject, EBulkType.None),
UNaniteDisplacedMesh => (EAssetCategory.NaniteDisplacedMesh, EBulkType.None),
UGeometryCollection => (EAssetCategory.GeometryCollection, EBulkType.None),
UGeometryCollection => (EAssetCategory.GeometryCollection, EBulkType.Meshes),
UTexture => (EAssetCategory.Texture, EBulkType.Textures),
@@ -245,18 +264,23 @@ public class GameFileViewModel(GameFile asset) : ViewModel
UObjectRedirector => (EAssetCategory.ObjectRedirector, EBulkType.None),
UPhysicalMaterial => (EAssetCategory.PhysicalMaterial, EBulkType.None),
USoundAtomCue or UAkAudioEvent or USoundCue or UFMODEvent
USoundAtomCue or UAtomSoundCue or UAkAudioEvent or USoundCue or UFMODEvent
or UAkAssetData or UAkAssetPlatformData => (EAssetCategory.AudioEvent, EBulkType.Audio),
UFMODBankLookup => (EAssetCategory.Data, EBulkType.None),
UFileManaMovie => (EAssetCategory.Video, EBulkType.None),
UFMODBus or UFMODSnapshot or UFMODSnapshotReverb or UFMODVCA or USQEXSEADSoundAttenuation => (EAssetCategory.Audio, EBulkType.None),
UFMODBankLookup or USoundAtomConfig or UFMODSnapshotReverb or UFMODBus or UFMODSnapshot
or UFMODVCA or UAtomDspBusSetting => (EAssetCategory.Data, EBulkType.None),
UFMODBank or UAkAudioBank or UAtomWaveBank or UAkInitBank or USQEXSEADSoundBank => (EAssetCategory.SoundBank, EBulkType.Audio),
USQEXSEADSoundAttenuation => (EAssetCategory.Audio, EBulkType.None),
UWwiseAssetLibrary or USoundBase or UAkMediaAssetData or UAtomCueSheet
or USoundAtomCueSheet or UAkAudioType or UExternalSource or UExternalSourceBank
or UAkMediaAsset => (EAssetCategory.Audio, EBulkType.Audio),
UFMODBank or UAkAudioBank or UAtomSoundBank or USoundAtomCueSheet
or UAkInitBank or USQEXSEADSoundBank => (EAssetCategory.SoundBank, EBulkType.Audio),
UWwiseAssetLibrary or UAtomSoundBase or USoundBase or UAkMediaAssetData
or UExternalSource or UExternalSourceBank or UAkMediaAsset => (EAssetCategory.Audio, EBulkType.Audio),
UAkAudioType => (EAssetCategory.Data, EBulkType.None),
UFileMediaSource => (EAssetCategory.Video, EBulkType.None),
UFont or UFontFace or USMGLocaleFontUMG => (EAssetCategory.Font, EBulkType.None),
@@ -269,6 +293,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel
UFaceFXAnimSet when GameVersion is EGame.GAME_Borderlands4 => (EAssetCategory.Borderlands, EBulkType.Audio), // Borderlands 4;
UWubAudioEvent or UWubDialogueEvent when GameVersion is EGame.GAME_LEGOBatmanLegacyoftheDarkKnight => (EAssetCategory.LegoBatman, EBulkType.Audio), // Lego Batman: Legacy of the Dark Knight;
UREDBinaryObject => (EAssetCategory.ArcSys, EBulkType.None), // Arc System Works games;
UOrpheusBank or UOrpheusEvent => (EAssetCategory.GothamKnights, EBulkType.Audio), // Gotham Knights;
_ => (EAssetCategory.All, EBulkType.None),
};
@@ -336,6 +361,15 @@ public class GameFileViewModel(GameFile asset) : ViewModel
{
Resolved |= EResolveCompute.Preview;
var lowercaseExtension = Asset.Extension.ToLowerInvariant();
AssetFamily = lowercaseExtension switch
{
"acb" or "acf" or "adx" or "awb" or "hca" or "usm" => EAssetFamily.Criware,
"bnk" or "pck" or "wem" => EAssetFamily.Wwise,
"bank" or "fsb" => EAssetFamily.FMod,
_ => EAssetFamily.None,
};
switch (lowercaseExtension)
{
case "uproject":
@@ -365,6 +399,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel
case "sql":
case "py":
case "cs":
case "acf":
AssetCategory = EAssetCategory.Data;
break;
case "stinfo":
@@ -375,6 +410,8 @@ public class GameFileViewModel(GameFile asset) : ViewModel
break;
case "wav":
case "awb": // This is technically soundbank and should be below but I want it to be distinguishable from "acb"
case "adx":
case "hca":
case "xvag":
case "flac":
case "at9":
@@ -385,6 +422,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel
break;
case "acb":
case "bank":
case "fsb":
case "bnk":
case "pck":
AssetCategory = EAssetCategory.SoundBank;
@@ -395,7 +433,9 @@ public class GameFileViewModel(GameFile asset) : ViewModel
case "ttf":
AssetCategory = EAssetCategory.Font;
break;
case "webm":
case "mp4":
case "usm":
AssetCategory = EAssetCategory.Video;
break;
case "jpg":

View File

@@ -58,7 +58,7 @@ public class GameSelectorViewModel : ViewModel
if (DetectedDirectories.FirstOrDefault(x => x.GameDirectory == gameDirectory) is { } detectedGame)
SelectedDirectory = detectedGame;
else if (!string.IsNullOrEmpty(gameDirectory))
else if (IsGameDirectoryAvailable(gameDirectory))
AddUndetectedDir(gameDirectory);
else
SelectedDirectory = DetectedDirectories.FirstOrDefault();
@@ -189,6 +189,39 @@ public class GameSelectorViewModel : ViewModel
SelectedDirectory = DetectedDirectories.Last();
}
public int ClearMissingDirectories()
{
var removedDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var missingDirectories = UserSettings.Default.PerDirectory
.Where(x => !IsGameDirectoryAvailable(x.Value.GameDirectory))
.Select(x => new KeyValuePair<string, string>(x.Key, x.Value.GameDirectory))
.ToArray();
foreach (var (key, gameDirectory) in missingDirectories)
{
UserSettings.Default.PerDirectory.Remove(key);
removedDirectories.Add(gameDirectory);
}
var missingDetectedDirectories = _detectedDirectories
.Where(x => !IsGameDirectoryAvailable(x.GameDirectory))
.ToArray();
foreach (var directory in missingDetectedDirectories)
{
_detectedDirectories.Remove(directory);
removedDirectories.Add(directory.GameDirectory);
}
if (SelectedDirectory is null || !_detectedDirectories.Contains(SelectedDirectory))
SelectedDirectory = DetectedDirectories.FirstOrDefault();
return removedDirectories.Count;
}
public static bool IsGameDirectoryAvailable(string gameDirectory)
=> gameDirectory is Constants._FN_LIVE_TRIGGER or Constants._VAL_LIVE_TRIGGER || Directory.Exists(gameDirectory);
private IEnumerable<EGame> EnumerateUeGames()
=> Enum.GetValues<EGame>()
.GroupBy(value => (int)value)
@@ -196,8 +229,8 @@ public class GameSelectorViewModel : ViewModel
.OrderBy(value => ((int)value & 0xFF) == 0);
private IEnumerable<DirectorySettings> EnumerateDetectedGames()
{
yield return GetUnrealEngineGame("Fortnite", "\\FortniteGame\\Content\\Paks", EGame.GAME_UE5_8);
yield return DirectorySettings.Default("Fortnite [LIVE]", Constants._FN_LIVE_TRIGGER, ue: EGame.GAME_UE5_8);
yield return GetUnrealEngineGame("Fortnite", "\\FortniteGame\\Content\\Paks", EGame.GAME_UE6_0);
yield return DirectorySettings.Default("Fortnite [LIVE]", Constants._FN_LIVE_TRIGGER, ue: EGame.GAME_UE6_0);
yield return GetUnrealEngineGame("Pewee", "\\RogueCompany\\Content\\Paks", EGame.GAME_RogueCompany);
yield return GetUnrealEngineGame("Rosemallow", "\\Indiana\\Content\\Paks", EGame.GAME_UE4_21);
yield return GetUnrealEngineGame("Catnip", "\\OakGame\\Content\\Paks", EGame.GAME_Borderlands3);

View File

@@ -130,14 +130,18 @@
adonisExtensions:LayerExtension.IncreaseLayer="True">
<Grid Margin="30, 12, 6, 12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="1" MinWidth="78" Margin="0 0 12 0" IsDefault="True" IsCancel="False"
<Button Grid.Column="0" MinWidth="78" Margin="0 0 12 0"
HorizontalAlignment="Left" VerticalAlignment="Bottom" Content="Clear"
ToolTip="Remove game directories that no longer exist" Click="OnClearDirectories" />
<Button Grid.Column="2" MinWidth="78" Margin="0 0 12 0" IsDefault="True" IsCancel="False"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Content="OK" Click="OnClick" />
<Button Grid.Column="2" MinWidth="78" Margin="0 0 12 0" IsDefault="False" IsCancel="True"
<Button Grid.Column="3" MinWidth="78" Margin="0 0 12 0" IsDefault="False" IsCancel="True"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Content="Cancel" />
</Grid>
</Border>

View File

@@ -1,6 +1,13 @@
using FModel.ViewModels;
using FModel.Views.Resources.Controls;
using Ookii.Dialogs.Wpf;
using Serilog;
using System.Windows;
using MessageBox = AdonisUI.Controls.MessageBox;
using MessageBoxButtons = AdonisUI.Controls.MessageBoxButtons;
using MessageBoxImage = AdonisUI.Controls.MessageBoxImage;
using MessageBoxModel = AdonisUI.Controls.MessageBoxModel;
using MessageBoxResult = AdonisUI.Controls.MessageBoxResult;
namespace FModel.Views;
@@ -63,6 +70,37 @@ public partial class DirectorySelector
gameLauncherViewModel.DeleteSelectedGame();
}
private void OnClearDirectories(object sender, RoutedEventArgs e)
{
if (DataContext is not GameSelectorViewModel gameSelectorViewModel)
return;
var messageBox = new MessageBoxModel
{
Text = "Remove all saved game directories that no longer exist?",
Caption = "Clear Empty Directories",
Icon = MessageBoxImage.Question,
Buttons = MessageBoxButtons.YesNo(),
IsSoundEnabled = false
};
MessageBox.Show(messageBox);
if (messageBox.Result != MessageBoxResult.Yes)
return;
var removedCount = gameSelectorViewModel.ClearMissingDirectories();
var message = removedCount switch
{
0 => "No empty directories were found",
1 => "Removed 1 empty game directory",
_ => $"Removed {removedCount} empty game directories"
};
Log.Information("{Message}", message);
if (FLogger.Logger is not null) // When only directory selector is open, FLogger.Logger is null
FLogger.Append(ELog.Information, () => FLogger.Text(message, Constants.WHITE, true));
}
public void AddManualGame(string directory)
{
ManualGameExpander.IsExpanded = true;

View File

@@ -11,6 +11,8 @@
<SolidColorBrush x:Key="UserDefinedEnumBrush" Color="DarkGoldenrod" />
<SolidColorBrush x:Key="UserDefinedStructBrush" Color="Tan" />
<SolidColorBrush x:Key="GroomAssetBrush" Color="RosyBrown " />
<SolidColorBrush x:Key="ChaosClothBrush" Color="LightSlateGray" />
<SolidColorBrush x:Key="CustomizableObjectBrush" Color="DarkRed" />
<SolidColorBrush x:Key="NaniteDisplacedMeshBrush" Color="ForestGreen" />
@@ -22,7 +24,7 @@
<SolidColorBrush x:Key="BinaryBrush" Color="Yellow" />
<SolidColorBrush x:Key="TextureBrush" Color="MediumPurple" />
<SolidColorBrush x:Key="ConfigBrush" Color="LightSlateGray" />
<SolidColorBrush x:Key="AudioBrush" Color="MediumSeaGreen" />
<SolidColorBrush x:Key="SoundBankBrush" Color="DarkSeaGreen" />
<SolidColorBrush x:Key="AudioEventBrush" Color="LightSeaGreen" />
@@ -52,6 +54,11 @@
<SolidColorBrush x:Key="PythonBrush" Color="#3d77a8" />
<SolidColorBrush x:Key="CSharpBrush" Color="#9e559a" />
<!-- Asset family brushes -->
<SolidColorBrush x:Key="CriwareBrush" Color="#005bab" />
<SolidColorBrush x:Key="WwiseBrush" Color="#0152a1" />
<SolidColorBrush x:Key="FModBrush" Color="#00758f" />
<!-- For specific games -->
<SolidColorBrush x:Key="BorderlandsBrush" Color="Yellow"></SolidColorBrush>
<SolidColorBrush x:Key="AionBrush" Color="DeepSkyBlue"></SolidColorBrush>

View File

@@ -44,12 +44,14 @@
<MultiBinding Converter="{x:Static converters:FileToGeometryConverter.Instance}">
<Binding Path="AssetCategory" />
<Binding Path="ResolvedAssetType" />
<Binding Path="AssetFamily" />
</MultiBinding>
</Path.Data>
<Path.Fill>
<MultiBinding Converter="{x:Static converters:FileToGeometryConverter.Instance}">
<Binding Path="AssetCategory" />
<Binding Path="ResolvedAssetType" />
<Binding Path="AssetFamily" />
</MultiBinding>
</Path.Fill>
</Path>

View File

@@ -35,12 +35,14 @@
<MultiBinding Converter="{x:Static converters:FileToGeometryConverter.Instance}">
<Binding Path="AssetCategory" />
<Binding Path="ResolvedAssetType" />
<Binding Path="AssetFamily" />
</MultiBinding>
</Path.Data>
<Path.Fill>
<MultiBinding Converter="{x:Static converters:FileToGeometryConverter.Instance}">
<Binding Path="AssetCategory" />
<Binding Path="ResolvedAssetType" />
<Binding Path="AssetFamily" />
</MultiBinding>
</Path.Fill>
</Path>

View File

@@ -5,6 +5,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Threading;
namespace FModel.Views.Resources.Controls;
@@ -17,8 +18,9 @@ public class FilterableComboBox : ComboBox
/// If true, on lost focus or enter key pressed, checks the text in the combobox. If the text is not present
/// in the list, it leaves it blank.
/// </summary>
public bool OnlyValuesInList {
get => (bool)GetValue(OnlyValuesInListProperty);
public bool OnlyValuesInList
{
get => (bool) GetValue(OnlyValuesInListProperty);
set => SetValue(OnlyValuesInListProperty, value);
}
public static readonly DependencyProperty OnlyValuesInListProperty =
@@ -27,8 +29,9 @@ public class FilterableComboBox : ComboBox
/// <summary>
/// Selected item, changes only on lost focus or enter key pressed
/// </summary>
public object EffectivelySelectedItem {
get => (bool)GetValue(EffectivelySelectedItemProperty);
public object EffectivelySelectedItem
{
get => (bool) GetValue(EffectivelySelectedItemProperty);
set => SetValue(EffectivelySelectedItemProperty, value);
}
public static readonly DependencyProperty EffectivelySelectedItemProperty =
@@ -38,6 +41,7 @@ public class FilterableComboBox : ComboBox
private bool TextBoxFreezed;
protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox;
private UserChange<bool> IsDropDownOpenUC;
private readonly DispatcherTimer FilterTimer;
/// <summary>
/// Triggers on lost focus or enter key pressed, if the selected item changed since the last time focus was lost or enter was pressed.
@@ -47,6 +51,15 @@ public class FilterableComboBox : ComboBox
public FilterableComboBox()
{
IsDropDownOpenUC = new UserChange<bool>(v => IsDropDownOpen = v);
FilterTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
{
Interval = TimeSpan.FromMilliseconds(125)
};
FilterTimer.Tick += (_, _) =>
{
FilterTimer.Stop();
RefreshFilter();
};
DropDownOpened += FilteredComboBox_DropDownOpened;
Focusable = true;
@@ -55,7 +68,8 @@ public class FilterableComboBox : ComboBox
StaysOpenOnEdit = true;
IsReadOnly = false;
Loaded += (s, e) => {
Loaded += (s, e) =>
{
if (EditableTextBox != null)
new TextBoxBaseUserChangeTracker(EditableTextBox).UserTextChanged += FilteredComboBox_UserTextChange;
};
@@ -67,25 +81,53 @@ public class FilterableComboBox : ComboBox
protected override void OnPreviewKeyDown(KeyEventArgs e)
{
if (IsDropDownOpen && Keyboard.Modifiers == ModifierKeys.None && e.Key is Key.Up or Key.Down)
{
MoveSelection(e.Key);
e.Handled = true;
return;
}
base.OnPreviewKeyDown(e);
if (e.Key == Key.Down && !IsDropDownOpen) {
if (e.Key == Key.Down && !IsDropDownOpen)
{
ApplyPendingFilter();
IsDropDownOpen = true;
e.Handled = true;
}
else if (e.Key == Key.Escape) {
else if (e.Key == Key.Escape)
{
ClearFilter();
Text = "";
IsDropDownOpen = true;
}
else if (e.Key == Key.Enter || e.Key == Key.Tab) {
else if (e.Key == Key.Enter || e.Key == Key.Tab)
{
ApplyPendingFilter();
CheckSelectedItem();
TriggerSelectedItemChanged();
}
}
private void MoveSelection(Key key)
{
ApplyPendingFilter();
if (Items.Count == 0)
return;
var index = SelectedItem == null ? -1 : Items.IndexOf(SelectedItem);
index = index < 0
? key == Key.Up ? Items.Count - 1 : 0
: Math.Clamp(index + (key == Key.Up ? -1 : 1), 0, Items.Count - 1);
SelectedItem = Items[index];
(ItemContainerGenerator.ContainerFromIndex(index) as ComboBoxItem)?.BringIntoView();
}
protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
{
base.OnPreviewLostKeyboardFocus(e);
ApplyPendingFilter();
CheckSelectedItem();
if ((e.OldFocus == this || e.OldFocus == EditableTextBox) && e.NewFocus != this && e.NewFocus != EditableTextBox)
TriggerSelectedItemChanged();
@@ -100,7 +142,8 @@ public class FilterableComboBox : ComboBox
private bool shouldTriggerSelectedItemChanged = false;
private void TriggerSelectedItemChanged()
{
if (shouldTriggerSelectedItemChanged) {
if (shouldTriggerSelectedItemChanged)
{
SelectionEffectivelyChanged?.Invoke(this, SelectedItem);
shouldTriggerSelectedItemChanged = false;
}
@@ -108,9 +151,12 @@ public class FilterableComboBox : ComboBox
public void ClearFilter()
{
if (string.IsNullOrEmpty(CurrentFilter)) return;
FilterTimer.Stop();
if (string.IsNullOrEmpty(CurrentFilter))
return;
CurrentFilter = "";
CollectionViewSource.GetDefaultView(ItemsSource).Refresh();
if (ItemsSource != null)
CollectionViewSource.GetDefaultView(ItemsSource).Refresh();
}
private void FilteredComboBox_DropDownOpened(object sender, EventArgs e)
@@ -121,47 +167,64 @@ public class FilterableComboBox : ComboBox
private void FilteredComboBox_UserTextChange(object sender, EventArgs e)
{
if (TextBoxFreezed) return;
if (TextBoxFreezed)
return;
var tb = EditableTextBox;
if (tb.SelectionStart + tb.SelectionLength == tb.Text.Length)
CurrentFilter = tb.Text.Substring(0, tb.SelectionStart).ToLower();
CurrentFilter = tb.Text.Substring(0, tb.SelectionStart);
else
CurrentFilter = tb.Text.ToLower();
RefreshFilter();
CurrentFilter = tb.Text;
FilterTimer.Stop();
FilterTimer.Start();
}
protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
{
if (newValue != null) {
FilterTimer.Stop();
if (newValue != null)
{
var view = CollectionViewSource.GetDefaultView(newValue);
view.Filter += FilterItem;
}
if (oldValue != null) {
if (oldValue != null)
{
var view = CollectionViewSource.GetDefaultView(oldValue);
if (view != null) view.Filter -= FilterItem;
if (view != null)
view.Filter -= FilterItem;
}
base.OnItemsSourceChanged(oldValue, newValue);
}
private void ApplyPendingFilter()
{
if (!FilterTimer.IsEnabled)
return;
FilterTimer.Stop();
RefreshFilter();
}
private void RefreshFilter()
{
if (ItemsSource == null) return;
if (ItemsSource == null)
return;
var view = CollectionViewSource.GetDefaultView(ItemsSource);
FreezTextBoxState(() => {
var isDropDownOpen = IsDropDownOpen;
//always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh()
IsDropDownOpenUC.Set(false);
FreezTextBoxState(() =>
{
view.Refresh();
if (!string.IsNullOrEmpty(CurrentFilter) || isDropDownOpen)
if (!IsDropDownOpen && !string.IsNullOrEmpty(CurrentFilter))
IsDropDownOpenUC.Set(true);
if (SelectedItem == null) {
if (SelectedItem == null)
{
foreach (var itm in ItemsSource)
if (itm.ToString() == Text) {
if (itm.ToString() == Text)
{
SelectedItem = itm;
break;
}
@@ -185,10 +248,16 @@ public class FilterableComboBox : ComboBox
private bool FilterItem(object value)
{
if (value == null) return false;
if (CurrentFilter.Length == 0) return true;
if (value == null)
return false;
if (CurrentFilter.Length == 0)
return true;
return value.ToString().ToLower().Contains(CurrentFilter);
var text = value.ToString();
if (text?.StartsWith("GAME_", StringComparison.OrdinalIgnoreCase) == true)
text = text[5..];
return text?.Contains(CurrentFilter, StringComparison.OrdinalIgnoreCase) == true;
}
private class TextBoxBaseUserChangeTracker
@@ -205,11 +274,13 @@ public class FilterableComboBox : ComboBox
TextBoxBase = textBoxBase;
LastText = TextBoxBase.ToString();
textBoxBase.PreviewTextInput += (s, e) => {
textBoxBase.PreviewTextInput += (s, e) =>
{
IsTextInput = true;
};
textBoxBase.TextChanged += (s, e) => {
textBoxBase.TextChanged += (s, e) =>
{
var isUserChange = PressedKeys.Count > 0 || IsTextInput || LastText == TextBoxBase.ToString();
IsTextInput = false;
LastText = TextBoxBase.ToString();
@@ -217,17 +288,21 @@ public class FilterableComboBox : ComboBox
UserTextChanged?.Invoke(this, e);
};
textBoxBase.PreviewKeyDown += (s, e) => {
switch (e.Key) {
textBoxBase.PreviewKeyDown += (s, e) =>
{
switch (e.Key)
{
case Key.Back:
case Key.Space:
if (!PressedKeys.Contains(e.Key))
PressedKeys.Add(e.Key);
break;
}
if (e.Key == Key.Back) {
if (e.Key == Key.Back)
{
var textBox = textBoxBase as TextBox;
if (textBox.SelectionStart > 0 && textBox.SelectionLength > 0 && (textBox.SelectionStart + textBox.SelectionLength) == textBox.Text.Length) {
if (textBox.SelectionStart > 0 && textBox.SelectionLength > 0 && (textBox.SelectionStart + textBox.SelectionLength) == textBox.Text.Length)
{
textBox.SelectionStart--;
textBox.SelectionLength++;
e.Handled = true;
@@ -236,12 +311,14 @@ public class FilterableComboBox : ComboBox
}
};
textBoxBase.PreviewKeyUp += (s, e) => {
textBoxBase.PreviewKeyUp += (s, e) =>
{
if (PressedKeys.Contains(e.Key))
PressedKeys.Remove(e.Key);
};
textBoxBase.LostFocus += (s, e) => {
textBoxBase.LostFocus += (s, e) =>
{
PressedKeys.Clear();
IsTextInput = false;
};
@@ -261,11 +338,13 @@ public class FilterableComboBox : ComboBox
public void Set(T val)
{
try {
try
{
IsUserChange = false;
action(val);
}
finally {
finally
{
IsUserChange = true;
}
}

View File

@@ -12,7 +12,7 @@ public class FileToGeometryConverter : IMultiValueConverter
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length < 2 || values[0] is not EAssetCategory category || values[1] is not string resolvedAssetType)
if (values.Length < 3 || values[0] is not EAssetCategory category || values[1] is not string resolvedAssetType || values[2] is not EAssetFamily family)
return null;
resolvedAssetType = resolvedAssetType.ToLowerInvariant();
@@ -32,6 +32,7 @@ public class FileToGeometryConverter : IMultiValueConverter
EAssetCategory.StaticMesh => ("StaticMeshIconAlt", "NeutralBrush"),
EAssetCategory.SkeletalMesh => ("SkeletalMeshIconAlt", "NeutralBrush"),
EAssetCategory.ChaosClothAsset => ("ChaosClothIcon", "ChaosClothBrush"),
EAssetCategory.CustomizableObject => ("StaticMeshIconAlt", "CustomizableObjectBrush"),
EAssetCategory.NaniteDisplacedMesh => ("StaticMeshIconAlt", "NaniteDisplacedMeshBrush"),
EAssetCategory.GeometryCollection => ("GeometryCollectionIcon", "NeutralBrush"),
@@ -99,12 +100,26 @@ public class FileToGeometryConverter : IMultiValueConverter
EAssetCategory.Aion2 => ("AionIcon", "AionBrush"),
EAssetCategory.RocoKingdomWorld => ("RocoKingdomWorldIcon", "RocoKingdomWorldBrush"),
EAssetCategory.DeltaForce => ("DeltaForceIcon", "DeltaForceBrush"),
EAssetCategory.LegoBatman => ("BatmanIcon", "BatmanBrush"),
EAssetCategory.LegoBatman or EAssetCategory.GothamKnights => ("BatmanIcon", "BatmanBrush"),
EAssetCategory.ArcSys => ("ArcSysIcon", "ArcSysBrush"),
_ => ("AssetIcon", "NeutralBrush")
};
(geometry, brush) = (family, category) switch
{
(EAssetFamily.Criware, EAssetCategory.Data) => ("CriwareIcon", "CriwareBrush"),
(EAssetFamily.Criware, _) => ("CriwareIcon", brush),
(EAssetFamily.Wwise, EAssetCategory.Data) => ("WwiseIcon", "WwiseBrush"),
(EAssetFamily.Wwise, _) => ("WwiseIcon", brush),
(EAssetFamily.FMod, EAssetCategory.Data) => ("FModIcon", "FModBrush"),
(EAssetFamily.FMod, _) => ("FModIcon", brush),
_ => (geometry, brush),
};
if (targetType == typeof(Geometry))
return Application.Current.FindResource(geometry) as Geometry;
if (targetType == typeof(Brush))

File diff suppressed because one or more lines are too long

View File

@@ -1682,6 +1682,16 @@
<Style x:Key="UComboBox" TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
<Setter Property="adonisExtensions:WatermarkExtension.Watermark" Value="UE5 / UE4 / GameName..." />
<Setter Property="ScrollViewer.CanContentScroll" Value="True" />
<Setter Property="VirtualizingPanel.IsVirtualizing" Value="True" />
<Setter Property="VirtualizingPanel.VirtualizationMode" Value="Recycling" />
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">