summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2018-08-01 11:07:29 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2018-08-01 11:07:29 -0400
commit60b41195778af33fd609eab66d9ae3f1d1165e8f (patch)
tree7128b906d40e94c56c34ed6058f27bc31c31a08b
parentb9bc1a6d17cafa0a97b46ffecda432cfc2f23b51 (diff)
parent52cf953f685c65b2b6814e375ec9a5ffa03c440a (diff)
downloadSMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.tar.gz
SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.tar.bz2
SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.zip
Merge branch 'develop' into stable
-rw-r--r--.editorconfig7
-rw-r--r--.github/CONTRIBUTING.md (renamed from docs/CONTRIBUTING.md)10
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md32
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.md15
-rw-r--r--.github/ISSUE_TEMPLATE/general.md15
-rw-r--r--.github/SUPPORT.md5
-rw-r--r--build/GlobalAssemblyInfo.cs4
-rw-r--r--build/common.targets139
-rw-r--r--build/prepare-install-package.targets27
-rw-r--r--build/prepare-nuget-package.targets2
-rw-r--r--docs/README.md14
-rw-r--r--docs/mod-build-config.md50
-rw-r--r--docs/release-notes.md116
-rw-r--r--docs/screenshots/code-analyzer-example.pngbin4022 -> 3473 bytes
-rw-r--r--docs/technical-docs.md111
-rw-r--r--src/SMAPI.AssemblyRewriters/Properties/AssemblyInfo.cs4
-rw-r--r--src/SMAPI.Common/Models/ModSeachModel.cs37
-rw-r--r--src/SMAPI.Common/SemanticVersionImpl.cs199
-rw-r--r--src/SMAPI.Common/StardewModdingAPI.Common.projitems19
-rw-r--r--src/SMAPI.Installer/Enums/Platform.cs12
-rw-r--r--src/SMAPI.Installer/InteractiveInstaller.cs271
-rw-r--r--src/SMAPI.Installer/StardewModdingAPI.Installer.csproj5
-rw-r--r--src/SMAPI.Installer/readme.txt12
-rw-r--r--src/SMAPI.Installer/unix-install.sh2
-rw-r--r--src/SMAPI.Installer/unix-launcher.sh6
-rw-r--r--src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs138
-rw-r--r--src/SMAPI.Internal/ConsoleWriting/LogLevel.cs30
-rw-r--r--src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs15
-rw-r--r--src/SMAPI.Internal/EnvironmentUtility.cs112
-rw-r--r--src/SMAPI.Internal/Platform.cs15
-rw-r--r--src/SMAPI.Internal/SMAPI.Internal.projitems18
-rw-r--r--src/SMAPI.Internal/StardewModdingAPI.Internal.shproj (renamed from src/SMAPI.Common/StardewModdingAPI.Common.shproj)4
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs10
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs9
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs6
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs4
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs9
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs32
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs5
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj4
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs93
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs188
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs24
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj2
-rw-r--r--src/SMAPI.ModBuildConfig/DeployModTask.cs33
-rw-r--r--src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs65
-rw-r--r--src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs4
-rw-r--r--src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj9
-rw-r--r--src/SMAPI.ModBuildConfig/build/smapi.targets52
-rw-r--r--src/SMAPI.ModBuildConfig/package.nuspec9
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs90
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs8
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs91
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs4
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs30
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs4
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs4
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs4
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs5
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs34
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs21
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs74
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/ModEntry.cs (renamed from src/SMAPI.Mods.ConsoleCommands/ConsoleCommandsMod.cs)2
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj6
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json2
-rw-r--r--src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs9
-rw-r--r--src/SMAPI.Mods.SaveBackup/ModEntry.cs133
-rw-r--r--src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs4
-rw-r--r--src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj (renamed from src/SMAPI.AssemblyRewriters/StardewModdingAPI.AssemblyRewriters.csproj)36
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json8
-rw-r--r--src/SMAPI.Tests/Core/ModResolverTests.cs91
-rw-r--r--src/SMAPI.Tests/StardewModdingAPI.Tests.csproj25
-rw-r--r--src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs (renamed from src/SMAPI.Tests/Core/PathUtilitiesTests.cs)4
-rw-r--r--src/SMAPI.Tests/Utilities/SemanticVersionTests.cs32
-rw-r--r--src/SMAPI.Tests/app.config6
-rw-r--r--src/SMAPI.Tests/packages.config9
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs180
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs48
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs248
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs48
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs4
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs50
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs11
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs3
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs (renamed from src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs)7
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs11
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs17
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs2
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs2
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs18
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs1
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs (renamed from src/SMAPI.Common/Models/ModInfoModel.cs)8
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs4
-rw-r--r--src/SMAPI.Web/Framework/VersionConstraint.cs4
-rw-r--r--src/SMAPI.Web/StardewModdingAPI.Web.csproj26
-rw-r--r--src/SMAPI.Web/Startup.cs26
-rw-r--r--src/SMAPI.Web/ViewModels/LogParserModel.cs57
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml46
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml227
-rw-r--r--src/SMAPI.Web/Views/Shared/_Layout.cshtml7
-rw-r--r--src/SMAPI.Web/appsettings.Development.json7
-rw-r--r--src/SMAPI.Web/appsettings.json17
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/index.css59
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css121
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/main.css38
-rw-r--r--src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.pngbin0 -> 250 bytes
-rw-r--r--src/SMAPI.Web/wwwroot/Content/images/nexus-icon.pngbin0 -> 927 bytes
-rw-r--r--src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.pngbin0 -> 1099 bytes
-rw-r--r--src/SMAPI.Web/wwwroot/Content/images/pufferchick.pngbin0 -> 831 bytes
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/index.js34
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js136
-rw-r--r--src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json1676
-rw-r--r--src/SMAPI.sln126
-rw-r--r--src/SMAPI/Constants.cs91
-rw-r--r--src/SMAPI/Context.cs4
-rw-r--r--src/SMAPI/Events/ControlEvents.cs36
-rw-r--r--src/SMAPI/Events/EventArgsGameLocationsChanged.cs27
-rw-r--r--src/SMAPI/Events/EventArgsInput.cs125
-rw-r--r--src/SMAPI/Events/EventArgsIntChanged.cs3
-rw-r--r--src/SMAPI/Events/EventArgsInventoryChanged.cs12
-rw-r--r--src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs39
-rw-r--r--src/SMAPI/Events/EventArgsLocationObjectsChanged.cs41
-rw-r--r--src/SMAPI/Events/EventArgsLocationsChanged.cs33
-rw-r--r--src/SMAPI/Events/EventArgsPlayerWarped.cs (renamed from src/SMAPI/Events/EventArgsCurrentLocationChanged.cs)13
-rw-r--r--src/SMAPI/Events/GameLoopLaunchedEventArgs.cs7
-rw-r--r--src/SMAPI/Events/GameLoopUpdatedEventArgs.cs36
-rw-r--r--src/SMAPI/Events/GameLoopUpdatingEventArgs.cs36
-rw-r--r--src/SMAPI/Events/IGameLoopEvents.cs17
-rw-r--r--src/SMAPI/Events/IInputEvents.cs20
-rw-r--r--src/SMAPI/Events/IModEvents.cs15
-rw-r--r--src/SMAPI/Events/IWorldEvents.cs29
-rw-r--r--src/SMAPI/Events/InputButtonPressedEventArgs.cs60
-rw-r--r--src/SMAPI/Events/InputButtonReleasedEventArgs.cs60
-rw-r--r--src/SMAPI/Events/InputCursorMovedEventArgs.cs30
-rw-r--r--src/SMAPI/Events/InputEvents.cs8
-rw-r--r--src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs38
-rw-r--r--src/SMAPI/Events/LocationEvents.cs24
-rw-r--r--src/SMAPI/Events/MultiplayerEvents.cs58
-rw-r--r--src/SMAPI/Events/PlayerEvents.cs10
-rw-r--r--src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs39
-rw-r--r--src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs38
-rw-r--r--src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs39
-rw-r--r--src/SMAPI/Events/WorldLocationListChangedEventArgs.cs33
-rw-r--r--src/SMAPI/Events/WorldNpcListChangedEventArgs.cs38
-rw-r--r--src/SMAPI/Events/WorldObjectListChangedEventArgs.cs40
-rw-r--r--src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs40
-rw-r--r--src/SMAPI/Framework/CommandManager.cs28
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs6
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs6
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs315
-rw-r--r--src/SMAPI/Framework/ContentCore.cs882
-rw-r--r--src/SMAPI/Framework/ContentManagerShim.cs100
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs297
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs252
-rw-r--r--src/SMAPI/Framework/ContentManagers/IContentManager.cs86
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs207
-rw-r--r--src/SMAPI/Framework/ContentPack.cs4
-rw-r--r--src/SMAPI/Framework/CursorPosition.cs14
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs168
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs20
-rw-r--r--src/SMAPI/Framework/Events/ManagedEventBase.cs9
-rw-r--r--src/SMAPI/Framework/Events/ModEvents.cs34
-rw-r--r--src/SMAPI/Framework/Events/ModEventsBase.cs28
-rw-r--r--src/SMAPI/Framework/Events/ModGameLoopEvents.cs43
-rw-r--r--src/SMAPI/Framework/Events/ModInputEvents.cs50
-rw-r--r--src/SMAPI/Framework/Events/ModWorldEvents.cs71
-rw-r--r--src/SMAPI/Framework/GameVersion.cs10
-rw-r--r--src/SMAPI/Framework/IModMetadata.cs28
-rw-r--r--src/SMAPI/Framework/Input/GamePadStateBuilder.cs162
-rw-r--r--src/SMAPI/Framework/Input/InputState.cs163
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs382
-rw-r--r--src/SMAPI/Framework/InternalExtensions.cs16
-rw-r--r--src/SMAPI/Framework/LegacyManifestVersion.cs26
-rw-r--r--src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs27
-rw-r--r--src/SMAPI/Framework/ModData/ModDatabase.cs206
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs78
-rw-r--r--src/SMAPI/Framework/ModHelpers/InputHelper.cs54
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModHelper.cs51
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs4
-rw-r--r--src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs40
-rw-r--r--src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs116
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs20
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs90
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs37
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs11
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs10
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs53
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs59
-rw-r--r--src/SMAPI/Framework/ModLoading/ModWarning.cs31
-rw-r--r--src/SMAPI/Framework/ModLoading/Platform.cs12
-rw-r--r--src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs13
-rw-r--r--src/SMAPI/Framework/ModLoading/RewriteHelper.cs18
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs56
-rw-r--r--src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs201
-rw-r--r--src/SMAPI/Framework/Models/ModFolderExport.cs21
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs16
-rw-r--r--src/SMAPI/Framework/Monitor.cs116
-rw-r--r--src/SMAPI/Framework/Patching/GamePatcher.cs45
-rw-r--r--src/SMAPI/Framework/Patching/IHarmonyPatch.cs15
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedField.cs3
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedMethod.cs3
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedProperty.cs3
-rw-r--r--src/SMAPI/Framework/RewriteFacades/SpriteBatchMethods.cs (renamed from src/SMAPI.AssemblyRewriters/SpriteBatchMethods.cs)4
-rw-r--r--src/SMAPI/Framework/SGame.cs1819
-rw-r--r--src/SMAPI/Framework/SMultiplayer.cs47
-rw-r--r--src/SMAPI/Framework/Serialisation/ColorConverter.cs (renamed from src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs)5
-rw-r--r--src/SMAPI/Framework/Serialisation/PointConverter.cs (renamed from src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs)5
-rw-r--r--src/SMAPI/Framework/Serialisation/RectangleConverter.cs (renamed from src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs)5
-rw-r--r--src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs36
-rw-r--r--src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs32
-rw-r--r--src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs31
-rw-r--r--src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs29
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/BaseDisposableWatcher.cs36
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs62
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs93
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs103
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetValueWatcher.cs83
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs86
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs79
-rw-r--r--src/SMAPI/Framework/StateTracking/ICollectionWatcher.cs17
-rw-r--r--src/SMAPI/Framework/StateTracking/IDictionaryWatcher.cs7
-rw-r--r--src/SMAPI/Framework/StateTracking/IValueWatcher.cs15
-rw-r--r--src/SMAPI/Framework/StateTracking/IWatcher.cs24
-rw-r--r--src/SMAPI/Framework/StateTracking/LocationTracker.cs103
-rw-r--r--src/SMAPI/Framework/StateTracking/PlayerTracker.cs169
-rw-r--r--src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs221
-rw-r--r--src/SMAPI/Framework/WatcherCore.cs119
-rw-r--r--src/SMAPI/GamePlatform.cs17
-rw-r--r--src/SMAPI/IAssetDataForImage.cs4
-rw-r--r--src/SMAPI/ICursorPosition.cs6
-rw-r--r--src/SMAPI/IInputHelper.cs21
-rw-r--r--src/SMAPI/IModHelper.cs11
-rw-r--r--src/SMAPI/IMultiplayerHelper.cs15
-rw-r--r--src/SMAPI/IPrivateField.cs30
-rw-r--r--src/SMAPI/IPrivateMethod.cs31
-rw-r--r--src/SMAPI/IPrivateProperty.cs30
-rw-r--r--src/SMAPI/IReflectionHelper.cs69
-rw-r--r--src/SMAPI/LogLevel.cs16
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs117
-rw-r--r--src/SMAPI/Metadata/InstructionMetadata.cs73
-rw-r--r--src/SMAPI/Program.cs746
-rw-r--r--src/SMAPI/SemanticVersion.cs58
-rw-r--r--src/SMAPI/StardewModdingAPI.config.json1853
-rw-r--r--src/SMAPI/StardewModdingAPI.csproj152
-rw-r--r--src/SMAPI/packages.config3
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifest.cs (renamed from src/SMAPI/IManifest.cs)4
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs (renamed from src/SMAPI/IManifestContentPackFor.cs)0
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifestDependency.cs (renamed from src/SMAPI/IManifestDependency.cs)0
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs (renamed from src/SMAPI/ISemanticVersion.cs)3
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs4
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/StardewModdingAPI.Toolkit.CoreInterfaces.csproj17
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs71
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs31
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs89
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs41
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs34
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs (renamed from src/SMAPI/Framework/WebApiClient.cs)32
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs161
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs36
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs27
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/MetadataModel.cs14
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataField.cs (renamed from src/SMAPI/Framework/ModData/ModDataField.cs)4
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs (renamed from src/SMAPI/Framework/ModData/ModDataFieldKey.cs)2
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataModel.cs (renamed from src/SMAPI/Framework/ModData/ModDataRecord.cs)45
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs134
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs (renamed from src/SMAPI/Framework/ModData/ParsedModDataRecord.cs)16
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDatabase.cs65
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModStatus.cs (renamed from src/SMAPI/Framework/ModData/ModStatus.cs)4
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs53
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs99
-rw-r--r--src/StardewModdingAPI.Toolkit/ModToolkit.cs89
-rw-r--r--src/StardewModdingAPI.Toolkit/Properties/AssemblyInfo.cs7
-rw-r--r--src/StardewModdingAPI.Toolkit/SemanticVersion.cs289
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs (renamed from src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs)10
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs (renamed from src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs)16
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs88
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs (renamed from src/SMAPI/Framework/Serialisation/SimpleReadOnlyConverter.cs)3
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs (renamed from src/SMAPI/Framework/Serialisation/SmapiConverters/StringEnumConverter.cs)2
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs21
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs (renamed from src/SMAPI/Framework/Serialisation/JsonHelper.cs)26
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs (renamed from src/SMAPI/Framework/Models/Manifest.cs)35
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs (renamed from src/SMAPI/Framework/Models/ManifestContentPackFor.cs)4
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs (renamed from src/SMAPI/Framework/Models/ManifestDependency.cs)4
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs (renamed from src/SMAPI/Framework/Exceptions/SParseException.cs)4
-rw-r--r--src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj26
-rw-r--r--src/StardewModdingAPI.Toolkit/Utilities/FileUtilities.cs46
-rw-r--r--src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs (renamed from src/SMAPI/Framework/Utilities/PathUtilities.cs)11
-rw-r--r--src/lib/0Harmony.dllbin0 -> 99840 bytes
-rw-r--r--src/lib/0Harmony.pdbbin0 -> 323072 bytes
292 files changed, 12386 insertions, 7733 deletions
diff --git a/.editorconfig b/.editorconfig
index a5cdcf97..126fdbd4 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,12 +12,11 @@ insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
-[*.csproj]
+[*.{csproj,json,nuspec,targets}]
indent_size = 2
-insert_final_newline = false
-[*.json]
-indent_size = 2
+[*.csproj]
+insert_final_newline = false
##########
## C# formatting
diff --git a/docs/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 52d47a4b..052cec2c 100644
--- a/docs/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,16 +1,18 @@
Do you want to...
* **Ask for help using SMAPI?**
- Please post a message in the [SMAPI support thread](http://community.playstarbound.com/threads/108375),
- don't create a GitHub issue.
+ Please post a message in the [SMAPI support thread](http://community.playstarbound.com/threads/108375)
+ or [ask on Discord](https://stardewvalleywiki.com/Modding:Community#Discord), don't create a
+ GitHub issue.
* **Report a bug?**
Please post a message in the [SMAPI support thread](http://community.playstarbound.com/threads/108375)
- instead, unless you're sure it's a bug in SMAPI itself.
+ or [ask on Discord](https://stardewvalleywiki.com/Modding:Community#Discord) instead, unless
+ you're sure it's a bug in SMAPI itself.
* **Submit a pull request?**
Pull requests are welcome! If you're submitting a new feature, it's best to discuss first to make
- sure it'll be accepted. Feel free to come chat in [#modding on Discord](https://discord.gg/kH55QXP)
+ sure it'll be accepted. Feel free to come chat in [#modding on Discord](https://stardewvalleywiki.com/Modding:Community#Discord)
or post in the [SMAPI support thread](http://community.playstarbound.com/threads/108375).
Documenting your code and using the same formatting conventions is appreciated, but don't worry too
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..7b5d5a6f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,32 @@
+---
+name: Bug report
+about: Report a problem with SMAPI.
+
+---
+
+<!--
+
+Only report a bug here if you're sure it's a SMAPI bug! To request support instead, see:
+ - #modding on Discord: https://stardewvalleywiki.com/Modding:Community#Discord
+ - support forum thread: https://community.playstarbound.com/threads/108375
+ - Nexus mod page: https://www.nexusmods.com/stardewvalley/mods/2400
+
+Replace the instructions below with the bug details.
+
+-->
+
+**Describe the bug**
+A clear and concise description of what the bug is. Provide any other details you think might be relevant here.
+
+**To Reproduce**
+Exact steps which reproduce the bug, if possible. For example:
+1. Load save '...'.
+2. Walk to '....'.
+3. Click '....'.
+4. Error occurs.
+
+**Log file**
+Upload your SMAPI log to https://log.smapi.io and post a link here.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..3f671ccc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,15 @@
+---
+name: Feature request
+about: Suggest an idea for SMAPI.
+
+---
+
+<!--
+
+GitHub issues are only used for development tasks. Please don't submit feature requests here! Instead, see...
+
+ - #modding on Discord: https://stardewvalleywiki.com/Modding:Community#Discord
+ - support forum thread: https://community.playstarbound.com/threads/108375
+ - Nexus page: https://www.nexusmods.com/stardewvalley/mods/2400
+
+-->
diff --git a/.github/ISSUE_TEMPLATE/general.md b/.github/ISSUE_TEMPLATE/general.md
new file mode 100644
index 00000000..00c31305
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/general.md
@@ -0,0 +1,15 @@
+---
+name: General
+about: Create a ticket about something else.
+
+---
+
+<!--
+
+GitHub issues are only used for development tasks. For support and questions, see...
+
+ - #modding on Discord: https://stardewvalleywiki.com/Modding:Community#Discord
+ - support forum thread: https://community.playstarbound.com/threads/108375
+ - Nexus page: https://www.nexusmods.com/stardewvalley/mods/2400
+
+-->
diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md
new file mode 100644
index 00000000..757aadee
--- /dev/null
+++ b/.github/SUPPORT.md
@@ -0,0 +1,5 @@
+GitHub issues are only used for SMAPI development tasks.
+
+To get help with SMAPI problems, you can...
+* [ask on Discord](https://stardewvalleywiki.com/Modding:Community#Discord);
+* or post in the [SMAPI support thread](https://community.playstarbound.com/threads/108375).
diff --git a/build/GlobalAssemblyInfo.cs b/build/GlobalAssemblyInfo.cs
index f2477486..bc0ddf69 100644
--- a/build/GlobalAssemblyInfo.cs
+++ b/build/GlobalAssemblyInfo.cs
@@ -1,5 +1,5 @@
using System.Reflection;
[assembly: AssemblyProduct("SMAPI")]
-[assembly: AssemblyVersion("2.5.5")]
-[assembly: AssemblyFileVersion("2.5.5")]
+[assembly: AssemblyVersion("2.6.0")]
+[assembly: AssemblyFileVersion("2.6.0")]
diff --git a/build/common.targets b/build/common.targets
index 6773dbe4..5b6511f8 100644
--- a/build/common.targets
+++ b/build/common.targets
@@ -14,65 +14,78 @@
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS</GamePath>
<!-- Windows paths -->
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath>
+ <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)') AND '$(OS)' == 'Windows_NT'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\GOG.com\Games\1453375253', 'PATH', null, RegistryView.Registry32))</GamePath>
<GamePath Condition="!Exists('$(GamePath)') AND '$(OS)' == 'Windows_NT'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32))</GamePath>
+
+ <!--compile constants -->
+ <DefineConstants Condition="$(OS) == 'Windows_NT'">$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants>
</PropertyGroup>
- <!-- add references-->
+ <!-- add common references -->
+ <ItemGroup>
+ <Reference Condition="'$(OS)' == 'Windows_NT' AND '$(MSBuildProjectName)' != 'StardewModdingAPI.Toolkit' AND '$(MSBuildProjectName)' != 'StardewModdingAPI.Toolkit.CoreInterfaces'" Include="System.Management" />
+ </ItemGroup>
+
+ <!-- add game references-->
<Choose>
- <When Condition="$(OS) == 'Windows_NT'">
- <PropertyGroup>
- <DefineConstants>$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants>
- </PropertyGroup>
- <ItemGroup>
- <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
- <Private>False</Private>
- </Reference>
- <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
- <Private>False</Private>
- </Reference>
- <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
- <Private>False</Private>
- </Reference>
- <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
- <Private>False</Private>
- </Reference>
- <Reference Include="Netcode" Condition="Exists('$(GamePath)\Netcode.dll')">
- <HintPath>$(GamePath)\Netcode.dll</HintPath>
- <Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
- </Reference>
- <Reference Include="Stardew Valley">
- <HintPath>$(GamePath)\Stardew Valley.exe</HintPath>
- <Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
- </Reference>
- <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86">
- <HintPath>$(GamePath)\xTile.dll</HintPath>
- <Private>False</Private>
- <SpecificVersion>False</SpecificVersion>
- </Reference>
- </ItemGroup>
+ <When Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.ConsoleCommands' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.SaveBackup' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Tests'">
+ <Choose>
+ <When Condition="$(OS) == 'Windows_NT'">
+ <ItemGroup>
+ <!--XNA framework-->
+ <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
+ <Private>False</Private>
+ </Reference>
+ <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
+ <Private>False</Private>
+ </Reference>
+ <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
+ <Private>False</Private>
+ </Reference>
+ <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
+ <Private>False</Private>
+ </Reference>
+
+ <!-- game DLLs -->
+ <Reference Include="Netcode">
+ <HintPath>$(GamePath)\Netcode.dll</HintPath>
+ <Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
+ </Reference>
+ <Reference Include="Stardew Valley">
+ <HintPath>$(GamePath)\Stardew Valley.exe</HintPath>
+ <Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
+ </Reference>
+ <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86">
+ <HintPath>$(GamePath)\xTile.dll</HintPath>
+ <Private>False</Private>
+ <SpecificVersion>False</SpecificVersion>
+ </Reference>
+ </ItemGroup>
+ </When>
+ <Otherwise>
+ <ItemGroup>
+ <!-- MonoGame -->
+ <Reference Include="MonoGame.Framework">
+ <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
+ <Private>False</Private>
+ <SpecificVersion>False</SpecificVersion>
+ </Reference>
+
+ <!-- game DLLs -->
+ <Reference Include="StardewValley">
+ <HintPath>$(GamePath)\StardewValley.exe</HintPath>
+ <Private>False</Private>
+ </Reference>
+ <Reference Include="xTile">
+ <HintPath>$(GamePath)\xTile.dll</HintPath>
+ <Private>False</Private>
+ </Reference>
+ </ItemGroup>
+ </Otherwise>
+ </Choose>
</When>
- <Otherwise>
- <PropertyGroup>
- <DefineConstants>$(DefineConstants);SMAPI_FOR_UNIX</DefineConstants>
- </PropertyGroup>
- <ItemGroup>
- <Reference Include="MonoGame.Framework">
- <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
- <Private>False</Private>
- <SpecificVersion>False</SpecificVersion>
- </Reference>
- <Reference Include="StardewValley">
- <HintPath>$(GamePath)\StardewValley.exe</HintPath>
- <Private>False</Private>
- </Reference>
- <Reference Include="xTile">
- <HintPath>$(GamePath)\xTile.dll</HintPath>
- <Private>False</Private>
- </Reference>
- </ItemGroup>
- </Otherwise>
</Choose>
<!-- if game path is invalid, show one user-friendly error instead of a slew of reference errors -->
@@ -82,21 +95,31 @@
<!-- copy files into game directory and enable debugging (only in debug mode) -->
<Target Name="AfterBuild">
- <CallTarget Targets="CopySMAPI;CopyDefaultMod" Condition="'$(Configuration)' == 'Debug'" />
+ <CallTarget Targets="CopySMAPI;CopyDefaultMods" Condition="'$(Configuration)' == 'Debug'" />
</Target>
<Target Name="CopySMAPI" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI'">
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).config.json" DestinationFolder="$(GamePath)" />
- <Copy SourceFiles="$(TargetDir)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(GamePath)" />
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).metadata.json" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" />
+ <Copy SourceFiles="$(TargetDir)\0Harmony.dll" DestinationFolder="$(GamePath)" />
+ <Copy SourceFiles="$(TargetDir)\0Harmony.pdb" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)" />
</Target>
- <Target Name="CopyDefaultMod" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.ConsoleCommands'">
- <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\Mods\ConsoleCommands" />
- <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\Mods\ConsoleCommands" Condition="Exists('$(TargetDir)\$(TargetName).pdb')" />
- <Copy SourceFiles="$(TargetDir)\manifest.json" DestinationFolder="$(GamePath)\Mods\ConsoleCommands" />
+ <Target Name="CopyDefaultMods" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.ConsoleCommands' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.SaveBackup'">
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)" />
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)" Condition="Exists('$(TargetDir)\$(TargetName).pdb')" />
+ <Copy SourceFiles="$(TargetDir)\manifest.json" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)" />
+ </Target>
+ <Target Name="CopyToolkit" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Toolkit' AND '$(Configuration)' == 'Debug' AND $(TargetFramework) == 'net4.5'" AfterTargets="PostBuildEvent">
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)" />
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" />
+ </Target>
+ <Target Name="CopyToolkitCoreInterfaces" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Toolkit.CoreInterfaces' AND '$(Configuration)' == 'Debug' AND $(TargetFramework) == 'net4.5'" AfterTargets="PostBuildEvent">
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)" />
+ <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" />
</Target>
<!-- launch SMAPI on debug -->
diff --git a/build/prepare-install-package.targets b/build/prepare-install-package.targets
index fca311f2..79185896 100644
--- a/build/prepare-install-package.targets
+++ b/build/prepare-install-package.targets
@@ -7,7 +7,9 @@
-->
<Target Name="AfterBuild">
<PropertyGroup>
- <CompiledSmapiPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</CompiledSmapiPath>
+ <CompiledRootPath>$(SolutionDir)\..\bin\$(Configuration)</CompiledRootPath>
+ <CompiledSmapiPath>$(CompiledRootPath)\SMAPI</CompiledSmapiPath>
+ <CompiledToolkitPath>$(CompiledRootPath)\SMAPI.Toolkit\net4.5</CompiledToolkitPath>
<PackagePath>$(SolutionDir)\..\bin\Packaged</PackagePath>
<PackageInternalPath>$(PackagePath)\internal</PackageInternalPath>
</PropertyGroup>
@@ -18,33 +20,50 @@
<RemoveDir Directories="$(PackagePath)" />
<!-- copy installer files -->
- <Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\install.exe" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\install on Windows.exe" />
<Copy SourceFiles="$(TargetDir)\readme.txt" DestinationFiles="$(PackagePath)\README.txt" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\unix-launcher.sh" DestinationFiles="$(PackageInternalPath)\Mono\StardewModdingAPI" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\unix-install.sh" DestinationFiles="$(PackagePath)\install.sh" />
<!-- copy SMAPI files for Mono -->
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackageInternalPath)\Mono\install.exe" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackageInternalPath)\Mono" />
- <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.config.json" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.metadata.json" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Numerics.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Runtime.Caching.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.xml" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
+ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="@(CompiledMods)" DestinationFolder="$(PackageInternalPath)\Mono\Mods\%(RecursiveDir)" />
<!-- copy SMAPI files for Windows -->
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackageInternalPath)\Windows" />
- <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.config.json" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.metadata.json" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.xml" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
+ <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="@(CompiledMods)" DestinationFolder="$(PackageInternalPath)\Windows\Mods\%(RecursiveDir)" />
</Target>
</Project>
diff --git a/build/prepare-nuget-package.targets b/build/prepare-nuget-package.targets
index 5dbc5508..11d1845e 100644
--- a/build/prepare-nuget-package.targets
+++ b/build/prepare-nuget-package.targets
@@ -13,6 +13,8 @@
<Copy SourceFiles="$(ProjectDir)/package.nuspec" DestinationFolder="$(PackagePath)" />
<Copy SourceFiles="$(ProjectDir)/build/smapi.targets" DestinationFiles="$(PackagePath)/build/Pathoschild.Stardew.ModBuildConfig.targets" />
<Copy SourceFiles="$(TargetDir)/StardewModdingAPI.ModBuildConfig.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.ModBuildConfig.dll" />
+ <Copy SourceFiles="$(TargetDir)/StardewModdingAPI.Toolkit.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.Toolkit.dll" />
+ <Copy SourceFiles="$(TargetDir)/StardewModdingAPI.Toolkit.CoreInterfaces.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.Toolkit.CoreInterfaces.dll" />
<Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/bin/netstandard1.3/StardewModdingAPI.ModBuildConfig.Analyzer.dll" DestinationFiles="$(PackagePath)/analyzers/dotnet/cs/StardewModdingAPI.ModBuildConfig.Analyzer.dll" />
<Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1" DestinationFiles="$(PackagePath)/tools/install.ps1" />
<Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1" DestinationFiles="$(PackagePath)/tools/uninstall.ps1" />
diff --git a/docs/README.md b/docs/README.md
index bdfc5c9d..e7d6d682 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,6 @@
**SMAPI** is an open-source modding API for [Stardew Valley](http://stardewvalley.net/) that lets
you play the game with mods. It's safely installed alongside the game's executable, and doesn't
-change any of your game files. It serves six main purposes:
+change any of your game files. It serves eight main purposes:
1. **Load mods into the game.**
_SMAPI loads mods when the game is starting up so they can interact with it. (Code mods aren't
@@ -25,16 +25,24 @@ change any of your game files. It serves six main purposes:
crashing the game, and makes it possible to troubleshoot errors in the game itself that would
otherwise show a generic 'program has stopped working' type of message._
-6. **Provide update checks.**
+6. **Provide update checks.**
_SMAPI automatically checks for new versions of your installed mods, and notifies you when any
are available._
+7. **Provide compatibility checks.**
+ _SMAPI automatically detects outdated or broken code in mods, and safely disables them before
+ they cause problems._
+
+8. **Back up your save files.**
+ _SMAPI automatically creates a daily backup of your saves and keeps ten backups, in case
+ something goes wrong. (Via the bundled SaveBackup mod.)_
+
## Documentation
Have questions? Come [chat on Discord](https://discord.gg/KCJHWhX) with SMAPI developers and other
modders!
### For players
-* [Modding guides](https://stardewvalleywiki.com/Modding:Index#For_players)
+* [Player guide](https://stardewvalleywiki.com/Modding:Player_Guide)
### For modders
* [Modding documentation](https://stardewvalleywiki.com/Modding:Index)
diff --git a/docs/mod-build-config.md b/docs/mod-build-config.md
index 99a567f2..0c1cc10a 100644
--- a/docs/mod-build-config.md
+++ b/docs/mod-build-config.md
@@ -121,18 +121,30 @@ or you have multiple installs, you can specify the path yourself. There's two wa
The configuration will check your custom path first, then fall back to the default paths (so it'll
still compile on a different computer).
-### Unit test projects
+### Ignore files
+If you don't want to include a file in the mod folder or release zip:
+* Make sure it's not copied to the build output. For a DLL, make sure the reference is [not marked 'copy local'](https://msdn.microsoft.com/en-us/library/t1zz5y8c(v=vs.100).aspx).
+* Or add this to your `.csproj` file under the `<Project` line:
+ ```xml
+ <IgnoreModFilePatterns>\.txt$, \.pdf$</IgnoreModFilePatterns>
+ ```
+ This is a comma-delimited list of regular expression patterns. If any pattern matches a file's
+ relative path in your mod folder, that file won't be included.
+
+### Non-mod projects
**(upcoming in 2.1)**
-You can use the package in unit test projects too. Its optional unit test mode...
+You can use the package in non-mod projects too (e.g. unit tests or framework DLLs). You'll need to
+disable deploying the mod and creating a release zip:
-1. disables deploying the project as a mod;
-2. disables creating a release zip;
-2. and copies the referenced DLLs into the build output for unit test frameworks.
+```xml
+<EnableModDeploy>False</EnableModDeploy>
+<EnableModZip>False</EnableModZip>
+```
-To enable it, add this above the first `</PropertyGroup>` in your `.csproj`:
+If this is for unit tests, you may need to copy the referenced DLLs into your build output too:
```xml
-<ModUnitTests>True</ModUnitTests>
+<CopyModReferencesToBuildOutput>True</CopyModReferencesToBuildOutput>
```
## Code warnings
@@ -140,11 +152,11 @@ To enable it, add this above the first `</PropertyGroup>` in your `.csproj`:
The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example:
![](screenshots/code-analyzer-example.png)
-You can hide the warnings...
+You can hide the warnings using the warning ID (shown under 'code' in the Error List). See...
* [for specific code](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-pragma-warning);
* for a method using this attribute:
```cs
- [System.Diagnostics.CodeAnalysis.SuppressMessage("SMAPI.CommonErrors", "SMAPI001")] // implicit net field conversion
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("SMAPI.CommonErrors", "AvoidNetField")]
```
* for an entire project:
1. Expand the _References_ node for the project in Visual Studio.
@@ -153,8 +165,8 @@ You can hide the warnings...
See below for help with each specific warning.
-### SMAPI001
-**Implicit net field conversion:**
+### Avoid implicit net field cast
+Warning text:
> This implicitly converts '{{expression}}' from {{net type}} to {{other type}}, but
> {{net type}} has unintuitive implicit conversion rules. Consider comparing against the actual
> value instead to avoid bugs.
@@ -163,11 +175,11 @@ Stardew Valley uses net types (like `NetBool` and `NetInt`) to handle multiplaye
can implicitly convert to their equivalent normal values (like `bool x = new NetBool()`), but their
conversion rules are unintuitive and error-prone. For example,
`item?.category == null && item?.category != null` can both be true at once, and
-`building.indoors != null` will be true for a null value in some cases.
+`building.indoors != null` can be true for a null value.
Suggested fix:
* Some net fields have an equivalent non-net property like `monster.Health` (`int`) instead of
- `monster.health` (`NetInt`). The package will add a separate [SMAPI002](#smapi002) warning for
+ `monster.health` (`NetInt`). The package will add a separate [AvoidNetField](#avoid-net-field) warning for
these. Use the suggested property instead.
* For a reference type (i.e. one that can contain `null`), you can use the `.Value` property:
```c#
@@ -185,17 +197,17 @@ Suggested fix:
if (item != null && item.category.Value == 0)
```
-### SMAPI002
-**Avoid net fields when possible:**
+### Avoid net field
+Warning text:
> '{{expression}}' is a {{net type}} field; consider using the {{property name}} property instead.
-Your code accesses a net field, which has some unusual behavior (see [SMAPI001](#smapi001)). This
-field has an equivalent non-net property that avoids those issues.
+Your code accesses a net field, which has some unusual behavior (see [AvoidImplicitNetFieldCast](#avoid-implicit-net-field-cast)).
+This field has an equivalent non-net property that avoids those issues.
Suggested fix: access the suggested property name instead.
-### SMAPI003
-**Avoid obsolete fields:**
+### Avoid obsolete field
+Warning text:
> The '{{old field}}' field is obsolete and should be replaced with '{{new field}}'.
Your code accesses a field which is obsolete or no longer works. Use the suggested field instead.
diff --git a/docs/release-notes.md b/docs/release-notes.md
index e68720da..e4f9fd1d 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -1,21 +1,103 @@
# Release notes
-<!--
-## 2.6 alpha
+## 2.6
* For players:
- * Added support for Stardew Valley 1.3+; no longer compatible with earlier versions.
- * Added `Context.IsMultiplayer` and `Context.IsMainPlayer` flags.
- * Fixed SMAPI update checks not showing newer beta versions when using a beta version.
+ * Updated for Stardew Valley 1.3.
+ * Added automatic save backups.
+ * Improved update checks:
+ * added beta update channel;
+ * added update alerts for incompatible mods with an unofficial update on the wiki;
+ * added update alerts for optional files on Nexus;
+ * added console warning for mods which don't have update checks configured;
+ * added more visible prompt in beta channel for SMAPI updates;
+ * fixed mod update checks failing if a mod only has prerelease versions on GitHub;
+ * fixed Nexus mod update alerts not showing HTTPS links.
+ * Improved mod warnings in the console.
+ * Improved error when game can't start audio.
+ * Improved the Console Commands mod:
+ * Added `player_add name`, which adds items to your inventory by name instead of ID.
+ * Fixed `world_setseason` not running season-change logic.
+ * Fixed `world_setseason` not normalising the season value.
+ * Fixed `world_settime` sometimes breaking NPC schedules (e.g. so they stay in bed).
+ * Removed the `player_setlevel` and `player_setspeed` commands, which weren't implemented in a useful way. Use a mod like CJB Cheats Menu if you need those.
+ * Fixed `SEHException` errors for some players.
+ * Fixed performance issues for some players.
+ * Fixed default color scheme on Mac or in PowerShell (configurable via `StardewModdingAPI.config.json`).
+ * Fixed installer error on Linux/Mac in some cases.
+ * Fixed installer not finding some game paths or showing duplicate paths.
+ * Fixed installer not removing some SMAPI files.
+ * Fixed launch issue for Linux players with some terminals. (Thanks to HanFox and kurumushi!)
+ * Fixed abort-retry loop if a mod crashed when intercepting assets during startup.
+ * Fixed some mods failing if the player name is blank.
+ * Fixed errors when a mod references a missing assembly.
+ * Fixed `AssemblyResolutionException` errors in rare cases.
+ * Renamed `install.exe` to `install on Windows.exe` to avoid confusion.
+ * Updated compatibility list.
+
+* For the web UI:
+ * Added option to download SMAPI from Nexus.
+ * Added log parser redesign that should be more intuitive.
+ * Added log parser option to view raw log.
+ * Changed log parser filters to show `DEBUG` messages by default.
+ * Fixed design on smaller screens.
+ * Fixed log parser issue when content packs have no description.
+ * Fixed log parser mangling crossplatform paths in some cases.
+ * Fixed `smapi.io/install` not linking to a useful page.
* For modders:
- * Added code analysis to mod build config package to flag common issues as warnings.
- * Dropped some deprecated APIs.
- * Fixed assets loaded by temporary content managers not being editable.
- * Fixed issue where assets didn't reload correctly when the player switches language.
+ * Added [input API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Input) for reading and suppressing keyboard, controller, and mouse input.
+ * Added code analysis in the NuGet package to flag common issues as warnings.
+ * Replaced `LocationEvents` to support multiplayer:
+ * now raised for all locations;
+ * now includes added/removed building interiors;
+ * each event now provides a list of added/removed values;
+ * added buildings-changed event.
+ * Added `Context.IsMultiplayer` and `Context.IsMainPlayer` flags.
+ * Added `Constants.TargetPlatform` which says whether the game is running on Linux, Mac, or Windows.
+ * Added `semanticVersion.IsPrerelease()` method.
+ * Added support for launching multiple instances transparently. This removes the former `--log-path` command-line argument.
+ * Added support for custom seasonal tilesheets when loading an unpacked `.tbin` map.
+ * Added Harmony DLL for internal use by SMAPI. (Mods should still include their own copy for backwards compatibility, and in case it's removed later. SMAPI will always load its own version though.)
+ * Added option to suppress update checks for a specific mod in `StardewModdingAPI.config.json`.
+ * Added absolute pixels to `ICursorPosition`.
+ * Added support for reading/writing `ISemanticVersion` to JSON.
+ * Added support for reloading NPC schedules through the content API.
+ * Reimplemented the content API so it works more reliably in many edge cases.
+ * Reimplemented input suppression to work more consistently in many cases.
+ * The order of update keys now affects which URL players see in update alerts.
+ * Fixed assets loaded by temporary content managers not being editable by mods.
+ * Fixed assets not reloaded consistently when the player switches language.
+ * Fixed error if a mod loads a PNG while the game is loading (e.g. custom map tilesheets via `IAssetLoader`).
+ * Fixed error if a mod translation file is empty.
+ * Fixed input suppression not working consistently for clicks.
+ * Fixed console input not saved to the log.
+ * Fixed `Context.IsPlayerFree` being false during festivals.
+ * Fixed `helper.ModRegistry.GetApi` errors not always mentioning which interface caused the issue.
+ * Fixed console commands being invoked asynchronously.
+ * Fixed mods able to intercept other mods' assets via the internal asset keys.
+ * Fixed mods able to indirectly change other mods' data through shared content caches.
+ * Fixed `SemanticVersion` allowing invalid versions in some cases.
+ * **Breaking changes** (see [migration guide](https://stardewvalleywiki.com/Modding:Migrate_to_Stardew_Valley_1.3)):
+ * Dropped some deprecated APIs.
+ * `LocationEvents` have been rewritten.
+ * Mods can't intercept chatbox input.
+ * Mod IDs should only contain letters, numbers, hyphens, dots, and underscores. That allows their use in many contexts like URLs. This restriction is now enforced. (In regex form: `^[a-zA-Z0-9_.-]+$`.)
* For SMAPI developers:
- * Added prerelease versions to the mod update-check API response where available (GitHub only).
- * Added support for beta releases on the home page.
--->
+ * Added more consistent crossplatform handling, including MacOS detection.
+ * Added beta update channel.
+ * Added optional mod metadata to the web API (including Nexus info, wiki metadata, etc).
+ * Added early prototype of SMAPI 3.0 events via `helper.Events`.
+ * Added early prototype of mod handler toolkit.
+ * Added Harmony for SMAPI's internal use.
+ * Added metadata dump option in `StardewModdingAPI.config.json` for troubleshooting some cases.
+ * Added more stylish pufferchick on the home page.
+ * Rewrote update checks:
+ * Moved most logic into the web API.
+ * Changed web API to require mod IDs.
+ * Changed web API to also fetch metadata from SMAPI's internal mod DB and the wiki.
+ * Rewrote world/player state tracking. The new implementation is much more efficient than previous method, uses net field events where available, and lays the groundwork for more advanced events in SMAPI 3.0.
+ * Split mod DB out of `StardewModdingAPI.config.json` into its own file.
+ * Updated to Mono.Cecil 0.10.
## 2.5.5
* For players:
@@ -215,7 +297,7 @@
* **New features for modders**
SMAPI 2.0 adds several features to enable new kinds of mods (see
- [API documentation](https://stardewvalleywiki.com/Modding:SMAPI_APIs)).
+ [API documentation](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs)).
The **content API** lets you edit, inject, and reload XNB data loaded by the game at any time. This lets SMAPI mods do
anything previously only possible with XNB mods, and enables new mod scenarios not possible with XNB mods (e.g.
@@ -338,8 +420,8 @@ For players:
* Updated mod compatibility list.
For modders:
-* Added `SDate` utility for in-game date calculations (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Dates)).
-* Added support for minimum dependency versions in `manifest.json` (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Manifest)).
+* Added `SDate` utility for in-game date calculations (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Utilities#Dates)).
+* Added support for minimum dependency versions in `manifest.json` (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest)).
* Added more useful logging when loading mods.
* Added a `ModID` property to all mod helpers for extension methods.
* Changed `manifest.MinimumApiVersion` from string to `ISemanticVersion`. This shouldn't affect mods unless they referenced that field in code.
@@ -371,8 +453,8 @@ For players:
* Updated mod compatibility list.
For modders:
-* You can now add dependencies to `manifest.json` (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Manifest)).
-* You can now translate your mod (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Translation)).
+* You can now add dependencies to `manifest.json` (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest)).
+* You can now translate your mod (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation)).
* You can now load unpacked `.tbin` files from your mod folder through the content API.
* SMAPI now automatically fixes tilesheet references for maps loaded from the mod folder.
<small>_When loading a map from the mod folder, SMAPI will automatically use tilesheets relative to the map file if they exists. Otherwise it will default to tilesheets in the game content._</small>
diff --git a/docs/screenshots/code-analyzer-example.png b/docs/screenshots/code-analyzer-example.png
index 3b930dc5..de38f643 100644
--- a/docs/screenshots/code-analyzer-example.png
+++ b/docs/screenshots/code-analyzer-example.png
Binary files differ
diff --git a/docs/technical-docs.md b/docs/technical-docs.md
index 9e1a49e7..d829baf9 100644
--- a/docs/technical-docs.md
+++ b/docs/technical-docs.md
@@ -44,7 +44,7 @@ executed. This doesn't work in MonoDevelop on Linux, unfortunately.
### Preparing a release
To prepare a crossplatform SMAPI release, you'll need to compile it on two platforms. See
-[crossplatforming info](http://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod#Test_on_all_platforms)
+[crossplatforming info](https://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod#Test_on_all_platforms)
on the wiki for the first-time setup.
1. Update the version number in `GlobalAssemblyInfo.cs` and `Constants::Version`. Make sure you use a
@@ -78,8 +78,9 @@ on the wiki for the first-time setup.
Mono.Cecil.dll
Newtonsoft.Json.dll
StardewModdingAPI
- StardewModdingAPI.AssemblyRewriters.dll
StardewModdingAPI.config.json
+ StardewModdingAPI.Internal.dll
+ StardewModdingAPI.metadata.json
StardewModdingAPI.exe
StardewModdingAPI.pdb
StardewModdingAPI.xml
@@ -91,8 +92,9 @@ on the wiki for the first-time setup.
Mods/*
Mono.Cecil.dll
Newtonsoft.Json.dll
- StardewModdingAPI.AssemblyRewriters.dll
StardewModdingAPI.config.json
+ StardewModdingAPI.Internal.dll
+ StardewModdingAPI.metadata.json
StardewModdingAPI.exe
StardewModdingAPI.pdb
StardewModdingAPI.xml
@@ -135,7 +137,6 @@ change without warning.
argument | purpose
-------- | -------
-`--log-path "path"` | The relative or absolute path of the log file SMAPI should write.
`--no-terminal` | SMAPI won't write anything to the console window. (Messages will still be written to the log file.)
### Compile flags
@@ -157,48 +158,66 @@ persisted in a compressed form to Pastebin.
The log parser lives at https://log.smapi.io.
-### Mods API
-The mods API provides version info for mods hosted by Chucklefish, GitHub, or Nexus Mods. It's used
-by SMAPI to perform update checks. The `{version}` URL token is the version of SMAPI making the
-request; it doesn't do anything currently, but lets us version breaking changes if needed.
-
-Each mod is identified by a repository key and unique identifier (like `nexus:541`). The following
-repositories are supported:
-
-key | repository
-------------- | ----------
-`chucklefish` | A mod page on the [Chucklefish mod site](https://community.playstarbound.com/resources/categories/22), identified by the mod ID in the page URL.
-`github` | A repository on [GitHub](https://github.com), identified by its owner and repository name (like `Zoryn4163/SMAPI-Mods`). This checks the version of the latest repository release.
-`nexus` | A mod page on [Nexus Mods](https://www.nexusmods.com/stardewvalley), identified by the mod ID in the page URL.
-
-
-The API accepts either `GET` or `POST` for convenience:
-> ```
->GET https://api.smapi.io/v2.0/mods?modKeys=nexus:541,chucklefish:4228
->```
-
->```
->POST https://api.smapi.io/v2.0/mods
->{
-> "ModKeys": [ "nexus:541", "chucklefish:4228" ]
->}
->```
-
-It returns a response like this:
->```
->{
-> "chucklefish:4228": {
-> "name": "Entoarox Framework",
-> "version": "1.8.0",
-> "url": "https://community.playstarbound.com/resources/4228"
-> },
-> "nexus:541": {
-> "name": "Lookup Anything",
-> "version": "1.16",
-> "url": "http://www.nexusmods.com/stardewvalley/mods/541"
-> }
->}
->```
+### Web API
+SMAPI provides a web API at `api.smapi.io` for use by SMAPI and external tools. The URL includes a
+`{version}` token, which is the SMAPI version for backwards compatibility. This API is publicly
+accessible but not officially released; it may change at any time.
+
+The API has one `/mods` endpoint. This provides mod info, including official versions and URLs
+(from Chucklefish, GitHub, or Nexus), unofficial versions from the wiki, and optional mod metadata
+from the wiki and SMAPI's internal data. This is used by SMAPI to perform update checks, and by
+external tools to fetch mod data.
+
+The API accepts a `POST` request with the mods to match, each of which **must** specify an ID and
+may _optionally_ specify [update keys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks).
+The API will automatically try to fetch known update keys from the wiki and internal data based on
+the given ID.
+
+```
+POST https://api.smapi.io/v2.0/mods
+{
+ "mods": [
+ {
+ "id": "Pathoschild.LookupAnything",
+ "updateKeys": [ "nexus:541", "chucklefish:4250" ]
+ }
+ ],
+ "includeExtendedMetadata": true
+}
+```
+
+The API will automatically aggregate versions and errors. Each mod will include...
+* an `id` (matching what you passed in);
+* up to three versions: `main` (e.g. 'latest version' field on Nexus), `optional` if newer (e.g.
+ optional files on Nexus), and `unofficial` if newer (from the wiki);
+* `metadata` with mod info crossreferenced from the wiki and internal data (only if you specified
+ `includeExtendedMetadata: true`);
+* and `errors` containing any error messages that occurred while fetching data.
+
+For example:
+```
+[
+ {
+ "id": "Pathoschild.LookupAnything",
+ "main": {
+ "version": "1.19",
+ "url": "https://www.nexusmods.com/stardewvalley/mods/541"
+ },
+ "metadata": {
+ "id": [
+ "Pathoschild.LookupAnything",
+ "LookupAnything"
+ ],
+ "name": "Lookup Anything",
+ "nexusID": 541,
+ "gitHubRepo": "Pathoschild/StardewMods",
+ "compatibilityStatus": "Ok",
+ "compatibilitySummary": "✓ use latest version."
+ },
+ "errors": []
+ }
+]
+```
## Development
### Local development
diff --git a/src/SMAPI.AssemblyRewriters/Properties/AssemblyInfo.cs b/src/SMAPI.AssemblyRewriters/Properties/AssemblyInfo.cs
deleted file mode 100644
index f456a30d..00000000
--- a/src/SMAPI.AssemblyRewriters/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-using System.Reflection;
-
-[assembly: AssemblyTitle("SMAPI.AssemblyRewriters")]
-[assembly: AssemblyDescription("Contains internal SMAPI classes used during assembly rewriting that need to be public for technical reasons, but shouldn't be visible to modders.")]
diff --git a/src/SMAPI.Common/Models/ModSeachModel.cs b/src/SMAPI.Common/Models/ModSeachModel.cs
deleted file mode 100644
index 3c33d0b6..00000000
--- a/src/SMAPI.Common/Models/ModSeachModel.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-
-namespace StardewModdingAPI.Common.Models
-{
- /// <summary>Specifies mods whose update-check info to fetch.</summary>
- internal class ModSearchModel
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The namespaced mod keys to search.</summary>
- public string[] ModKeys { get; set; }
-
- /// <summary>Whether to allow non-semantic versions, instead of returning an error for those.</summary>
- public bool AllowInvalidVersions { get; set; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an empty instance.</summary>
- public ModSearchModel()
- {
- // needed for JSON deserialising
- }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="modKeys">The namespaced mod keys to search.</param>
- /// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param>
- public ModSearchModel(IEnumerable<string> modKeys, bool allowInvalidVersions)
- {
- this.ModKeys = modKeys.ToArray();
- this.AllowInvalidVersions = allowInvalidVersions;
- }
- }
-}
diff --git a/src/SMAPI.Common/SemanticVersionImpl.cs b/src/SMAPI.Common/SemanticVersionImpl.cs
deleted file mode 100644
index 084f56a3..00000000
--- a/src/SMAPI.Common/SemanticVersionImpl.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-using System;
-using System.Text.RegularExpressions;
-
-namespace StardewModdingAPI.Common
-{
- /// <summary>A low-level implementation of a semantic version with an optional release tag.</summary>
- /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks>
- internal class SemanticVersionImpl
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The major version incremented for major API changes.</summary>
- public int Major { get; }
-
- /// <summary>The minor version incremented for backwards-compatible changes.</summary>
- public int Minor { get; }
-
- /// <summary>The patch version for backwards-compatible bug fixes.</summary>
- public int Patch { get; }
-
- /// <summary>An optional prerelease tag.</summary>
- public string Tag { get; }
-
- /// <summary>A regex pattern matching a version within a larger string.</summary>
- internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\-\.]?)+))?";
-
- /// <summary>A regular expression matching a semantic version string.</summary>
- /// <remarks>
- /// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>,
- /// with three important deviations intended to support Stardew Valley mod conventions:
- /// - allows short-form "x.y" versions;
- /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3");
- /// - doesn't allow '+build' suffixes.
- /// </remarks>
- internal static readonly Regex Regex = new Regex($@"^{SemanticVersionImpl.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture);
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="major">The major version incremented for major API changes.</param>
- /// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
- /// <param name="patch">The patch version for backwards-compatible bug fixes.</param>
- /// <param name="tag">An optional prerelease tag.</param>
- public SemanticVersionImpl(int major, int minor, int patch, string tag = null)
- {
- this.Major = major;
- this.Minor = minor;
- this.Patch = patch;
- this.Tag = this.GetNormalisedTag(tag);
- }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="version">The assembly version.</param>
- /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
- public SemanticVersionImpl(Version version)
- {
- if (version == null)
- throw new ArgumentNullException(nameof(version), "The input version can't be null.");
-
- this.Major = version.Major;
- this.Minor = version.Minor;
- this.Patch = version.Build;
- }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="version">The semantic version string.</param>
- /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
- /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
- public SemanticVersionImpl(string version)
- {
- // parse
- if (version == null)
- throw new ArgumentNullException(nameof(version), "The input version string can't be null.");
- var match = SemanticVersionImpl.Regex.Match(version.Trim());
- if (!match.Success)
- throw new FormatException($"The input '{version}' isn't a valid semantic version.");
-
- // initialise
- this.Major = int.Parse(match.Groups["major"].Value);
- this.Minor = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
- this.Patch = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
- this.Tag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
- }
-
- /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
- /// <param name="other">The version to compare with this instance.</param>
- /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception>
- public int CompareTo(SemanticVersionImpl other)
- {
- if (other == null)
- throw new ArgumentNullException(nameof(other));
- return this.CompareTo(other.Major, other.Minor, other.Patch, other.Tag);
- }
-
-
- /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
- /// <param name="otherMajor">The major version to compare with this instance.</param>
- /// <param name="otherMinor">The minor version to compare with this instance.</param>
- /// <param name="otherPatch">The patch version to compare with this instance.</param>
- /// <param name="otherTag">The prerelease tag to compare with this instance.</param>
- public int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag)
- {
- const int same = 0;
- const int curNewer = 1;
- const int curOlder = -1;
-
- // compare stable versions
- if (this.Major != otherMajor)
- return this.Major.CompareTo(otherMajor);
- if (this.Minor != otherMinor)
- return this.Minor.CompareTo(otherMinor);
- if (this.Patch != otherPatch)
- return this.Patch.CompareTo(otherPatch);
- if (this.Tag == otherTag)
- return same;
-
- // stable supercedes pre-release
- bool curIsStable = string.IsNullOrWhiteSpace(this.Tag);
- bool otherIsStable = string.IsNullOrWhiteSpace(otherTag);
- if (curIsStable)
- return curNewer;
- if (otherIsStable)
- return curOlder;
-
- // compare two pre-release tag values
- string[] curParts = this.Tag.Split('.', '-');
- string[] otherParts = otherTag.Split('.', '-');
- for (int i = 0; i < curParts.Length; i++)
- {
- // longer prerelease tag supercedes if otherwise equal
- if (otherParts.Length <= i)
- return curNewer;
-
- // compare if different
- if (curParts[i] != otherParts[i])
- {
- // compare numerically if possible
- {
- if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum))
- return curNum.CompareTo(otherNum);
- }
-
- // else compare lexically
- return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase);
- }
- }
-
- // fallback (this should never happen)
- return string.Compare(this.ToString(), new SemanticVersionImpl(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
- }
-
- /// <summary>Get a string representation of the version.</summary>
- public override string ToString()
- {
- // version
- string result = this.Patch != 0
- ? $"{this.Major}.{this.Minor}.{this.Patch}"
- : $"{this.Major}.{this.Minor}";
-
- // tag
- string tag = this.Tag;
- if (tag != null)
- result += $"-{tag}";
- return result;
- }
-
- /// <summary>Parse a version string without throwing an exception if it fails.</summary>
- /// <param name="version">The version string.</param>
- /// <param name="parsed">The parsed representation.</param>
- /// <returns>Returns whether parsing the version succeeded.</returns>
- internal static bool TryParse(string version, out SemanticVersionImpl parsed)
- {
- try
- {
- parsed = new SemanticVersionImpl(version);
- return true;
- }
- catch
- {
- parsed = null;
- return false;
- }
- }
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>Get a normalised build tag.</summary>
- /// <param name="tag">The tag to normalise.</param>
- private string GetNormalisedTag(string tag)
- {
- tag = tag?.Trim();
- return !string.IsNullOrWhiteSpace(tag) ? tag : null;
- }
- }
-}
diff --git a/src/SMAPI.Common/StardewModdingAPI.Common.projitems b/src/SMAPI.Common/StardewModdingAPI.Common.projitems
deleted file mode 100644
index 223b0d5c..00000000
--- a/src/SMAPI.Common/StardewModdingAPI.Common.projitems
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
- <PropertyGroup>
- <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
- <HasSharedItems>true</HasSharedItems>
- <SharedGUID>2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc</SharedGUID>
- </PropertyGroup>
- <PropertyGroup Label="Configuration">
- <Import_RootNamespace>StardewModdingAPI.Common</Import_RootNamespace>
- </PropertyGroup>
- <ItemGroup>
- <Compile Include="$(MSBuildThisFileDirectory)Models\ModSeachModel.cs" />
- <Compile Include="$(MSBuildThisFileDirectory)Models\ModInfoModel.cs" />
- <Compile Include="$(MSBuildThisFileDirectory)SemanticVersionImpl.cs" />
- </ItemGroup>
- <ItemGroup>
- <Folder Include="$(MSBuildThisFileDirectory)Models\" />
- </ItemGroup>
-</Project> \ No newline at end of file
diff --git a/src/SMAPI.Installer/Enums/Platform.cs b/src/SMAPI.Installer/Enums/Platform.cs
deleted file mode 100644
index 9bcaa3c3..00000000
--- a/src/SMAPI.Installer/Enums/Platform.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace StardewModdingApi.Installer.Enums
-{
- /// <summary>The game's platform version.</summary>
- internal enum Platform
- {
- /// <summary>The Linux/Mac version of the game.</summary>
- Mono,
-
- /// <summary>The Windows version of the game.</summary>
- Windows
- }
-} \ No newline at end of file
diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs
index b7e698ad..f39486e1 100644
--- a/src/SMAPI.Installer/InteractiveInstaller.cs
+++ b/src/SMAPI.Installer/InteractiveInstaller.cs
@@ -7,7 +7,8 @@ using System.Reflection;
using System.Threading;
using Microsoft.Win32;
using StardewModdingApi.Installer.Enums;
-using StardewModdingAPI.Common;
+using StardewModdingAPI.Internal;
+using StardewModdingAPI.Internal.ConsoleWriting;
namespace StardewModdingApi.Installer
{
@@ -17,6 +18,15 @@ namespace StardewModdingApi.Installer
/*********
** Properties
*********/
+ /// <summary>The name of the installer file in the package.</summary>
+ private readonly string InstallerFileName = "install.exe";
+
+ /// <summary>Mod files which shouldn't be deleted when deploying bundled mods (mod folder name => file names).</summary>
+ private readonly IDictionary<string, HashSet<string>> ProtectBundledFiles = new Dictionary<string, HashSet<string>>(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ["SaveBackup"] = new HashSet<string>(new[] { "backups", "config.json" }, StringComparer.InvariantCultureIgnoreCase)
+ };
+
/// <summary>The <see cref="Environment.OSVersion"/> value that represents Windows 7.</summary>
private readonly Version Windows7Version = new Version(6, 1);
@@ -27,7 +37,8 @@ namespace StardewModdingApi.Installer
{
switch (platform)
{
- case Platform.Mono:
+ case Platform.Linux:
+ case Platform.Mac:
{
string home = Environment.GetEnvironmentVariable("HOME");
@@ -61,6 +72,11 @@ namespace StardewModdingApi.Installer
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
+
+ // via Steam library path
+ string steampath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath");
+ if (steampath != null)
+ yield return Path.Combine(steampath.Replace('/', '\\'), @"steamapps\common\Stardew Valley");
}
break;
@@ -77,12 +93,20 @@ namespace StardewModdingApi.Installer
string GetInstallPath(string path) => Path.Combine(installDir.FullName, path);
// common
+ yield return GetInstallPath("0Harmony.dll");
+ yield return GetInstallPath("0Harmony.pdb");
yield return GetInstallPath("Mono.Cecil.dll");
yield return GetInstallPath("Newtonsoft.Json.dll");
yield return GetInstallPath("StardewModdingAPI.exe");
yield return GetInstallPath("StardewModdingAPI.config.json");
- yield return GetInstallPath("StardewModdingAPI.data.json");
- yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll");
+ yield return GetInstallPath("StardewModdingAPI.metadata.json");
+ yield return GetInstallPath("StardewModdingAPI.Toolkit.dll");
+ yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb");
+ yield return GetInstallPath("StardewModdingAPI.Toolkit.xml");
+ yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll");
+ yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb");
+ yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml");
+ yield return GetInstallPath("StardewModdingAPI.xml");
yield return GetInstallPath("System.ValueTuple.dll");
yield return GetInstallPath("steam_appid.txt");
@@ -101,6 +125,7 @@ namespace StardewModdingApi.Installer
yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // *–2.0 (renamed to ConsoleCommands)
yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8
yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4
+ yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3-2.5.5
if (modsDir.Exists)
{
foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories())
@@ -109,24 +134,30 @@ namespace StardewModdingApi.Installer
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files
}
- /// <summary>Whether the current console supports color formatting.</summary>
- private static readonly bool ConsoleSupportsColor = InteractiveInstaller.GetConsoleSupportsColor();
+ /// <summary>Handles writing color-coded text to the console.</summary>
+ private readonly ColorfulConsoleWriter ConsoleWriter;
/*********
** Public methods
*********/
+ /// <summary>Construct an instance.</summary>
+ public InteractiveInstaller()
+ {
+ this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.AutoDetect);
+ }
+
/// <summary>Run the install or uninstall script.</summary>
/// <param name="args">The command line arguments.</param>
/// <remarks>
/// Initialisation flow:
/// 1. Collect information (mainly OS and install path) and validate it.
/// 2. Ask the user whether to install or uninstall.
- ///
+ ///
/// Uninstall logic:
/// 1. On Linux/Mac: if a backup of the launcher exists, delete the launcher and restore the backup.
/// 2. Delete all files and folders in the game directory matching one of the values returned by <see cref="GetUninstallPaths"/>.
- ///
+ ///
/// Install flow:
/// 1. Run the uninstall flow.
/// 2. Copy the SMAPI files from package/Windows or package/Mono into the game directory.
@@ -140,10 +171,19 @@ namespace StardewModdingApi.Installer
/****
** Get platform & set window title
****/
- Platform platform = this.DetectPlatform();
- Console.Title = $"SMAPI {new SemanticVersionImpl(this.GetType().Assembly.GetName().Version)} installer on {platform}";
+ Platform platform = EnvironmentUtility.DetectPlatform();
+ Console.Title = $"SMAPI {this.GetDisplayVersion(this.GetType().Assembly.GetName().Version)} installer on {platform} {EnvironmentUtility.GetFriendlyPlatformName(platform)}";
Console.WriteLine();
+#if SMAPI_FOR_WINDOWS
+ if (platform == Platform.Linux || platform == Platform.Mac)
+ {
+ this.PrintError($"This is the installer for Windows. Run the 'install on {platform}.{(platform == Platform.Linux ? "sh" : "command")}' file instead.");
+ Console.ReadLine();
+ return;
+ }
+#endif
+
/****
** read command-line arguments
****/
@@ -178,18 +218,20 @@ namespace StardewModdingApi.Installer
}
// get folders
- DirectoryInfo packageDir = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", platform.ToString()));
+ DirectoryInfo packageDir = platform.IsMono()
+ ? new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)) // installer runs from internal folder on Mono
+ : new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", "Windows"));
DirectoryInfo modsDir = new DirectoryInfo(Path.Combine(installDir.FullName, "Mods"));
var paths = new
{
- executable = Path.Combine(installDir.FullName, platform == Platform.Mono ? "StardewValley.exe" : "Stardew Valley.exe"),
+ executable = Path.Combine(installDir.FullName, EnvironmentUtility.GetExecutableName(platform)),
unixSmapiLauncher = Path.Combine(installDir.FullName, "StardewModdingAPI"),
unixLauncher = Path.Combine(installDir.FullName, "StardewValley"),
unixLauncherBackup = Path.Combine(installDir.FullName, "StardewValley-original")
};
// show output
- Console.WriteLine($"Your game folder: {installDir}.");
+ this.PrintInfo($"Your game folder: {installDir}.");
/****
** validate assumptions
@@ -198,7 +240,7 @@ namespace StardewModdingApi.Installer
{
this.PrintError(platform == Platform.Windows && packageDir.FullName.Contains(Path.GetTempPath()) && packageDir.FullName.Contains(".zip")
? "The installer is missing some files. It looks like you're running the installer from inside the downloaded zip; make sure you unzip the downloaded file first, then run the installer from the unzipped folder."
- : $"The 'internal/{platform}' package folder is missing (should be at {packageDir})."
+ : $"The 'internal/{packageDir.Name}' package folder is missing (should be at {packageDir})."
);
Console.ReadLine();
return;
@@ -226,7 +268,7 @@ namespace StardewModdingApi.Installer
Console.ReadLine();
return;
}
- if (!this.HasXNA(platform))
+ if (!this.HasXna(platform))
{
this.PrintError("You don't seem to have XNA Framework installed. Please run the game at least once before installing SMAPI, so it can perform its first-time setup.");
Console.ReadLine();
@@ -247,9 +289,9 @@ namespace StardewModdingApi.Installer
action = ScriptAction.Uninstall;
else
{
- Console.WriteLine("You can....");
- Console.WriteLine("[1] Install SMAPI.");
- Console.WriteLine("[2] Uninstall SMAPI.");
+ this.PrintInfo("You can....");
+ this.PrintInfo("[1] Install SMAPI.");
+ this.PrintInfo("[2] Uninstall SMAPI.");
Console.WriteLine();
string choice = this.InteractivelyChoose("What do you want to do? Type 1 or 2, then press enter.", "1", "2");
@@ -271,7 +313,7 @@ namespace StardewModdingApi.Installer
** Always uninstall old files
****/
// restore game launcher
- if (platform == Platform.Mono && File.Exists(paths.unixLauncherBackup))
+ if (platform.IsMono() && File.Exists(paths.unixLauncherBackup))
{
this.PrintDebug("Removing SMAPI launcher...");
this.InteractivelyDelete(paths.unixLauncher);
@@ -298,19 +340,25 @@ namespace StardewModdingApi.Installer
this.PrintDebug("Adding SMAPI files...");
foreach (FileInfo sourceFile in packageDir.EnumerateFiles().Where(this.ShouldCopyFile))
{
+ if (sourceFile.Name == this.InstallerFileName)
+ continue;
+
string targetPath = Path.Combine(installDir.FullName, sourceFile.Name);
this.InteractivelyDelete(targetPath);
sourceFile.CopyTo(targetPath);
}
// replace mod launcher (if possible)
- if (platform == Platform.Mono)
+ if (platform.IsMono())
{
this.PrintDebug("Safely replacing game launcher...");
- if (!File.Exists(paths.unixLauncherBackup))
- File.Move(paths.unixLauncher, paths.unixLauncherBackup);
- else if (File.Exists(paths.unixLauncher))
- this.InteractivelyDelete(paths.unixLauncher);
+ if (File.Exists(paths.unixLauncher))
+ {
+ if (!File.Exists(paths.unixLauncherBackup))
+ File.Move(paths.unixLauncher, paths.unixLauncherBackup);
+ else
+ this.InteractivelyDelete(paths.unixLauncher);
+ }
File.Move(paths.unixSmapiLauncher, paths.unixLauncher);
}
@@ -323,19 +371,42 @@ namespace StardewModdingApi.Installer
}
// add or replace bundled mods
- Directory.CreateDirectory(Path.Combine(installDir.FullName, "Mods"));
+ modsDir.Create();
DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "Mods"));
if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any())
{
this.PrintDebug("Adding bundled mods...");
+
+ // special case: rename Omegasis' SaveBackup mod
+ {
+ DirectoryInfo oldFolder = new DirectoryInfo(Path.Combine(modsDir.FullName, "SaveBackup"));
+ DirectoryInfo newFolder = new DirectoryInfo(Path.Combine(modsDir.FullName, "AdvancedSaveBackup"));
+ FileInfo manifest = new FileInfo(Path.Combine(oldFolder.FullName, "manifest.json"));
+ if (manifest.Exists && !newFolder.Exists && File.ReadLines(manifest.FullName).Any(p => p.IndexOf("Omegasis", StringComparison.InvariantCultureIgnoreCase) != -1))
+ {
+ this.PrintDebug($" moving {oldFolder.Name} to {newFolder.Name}...");
+ this.Move(oldFolder, newFolder.FullName);
+ }
+ }
+
+ // add bundled mods
foreach (DirectoryInfo sourceDir in packagedModsDir.EnumerateDirectories())
{
this.PrintDebug($" adding {sourceDir.Name}...");
- // initialise target dir
+ // init/clear target dir
DirectoryInfo targetDir = new DirectoryInfo(Path.Combine(modsDir.FullName, sourceDir.Name));
- this.InteractivelyDelete(targetDir.FullName);
- targetDir.Create();
+ if (targetDir.Exists)
+ {
+ this.ProtectBundledFiles.TryGetValue(targetDir.Name, out HashSet<string> protectedFiles);
+ foreach (FileSystemInfo entry in targetDir.EnumerateFileSystemInfos())
+ {
+ if (protectedFiles == null || !protectedFiles.Contains(entry.Name))
+ this.InteractivelyDelete(entry.FullName);
+ }
+ }
+ else
+ targetDir.Create();
// copy files
foreach (FileInfo sourceFile in sourceDir.EnumerateFiles().Where(this.ShouldCopyFile))
@@ -344,7 +415,7 @@ namespace StardewModdingApi.Installer
}
// remove obsolete appdata mods
- this.InteractivelyRemoveAppDataMods(platform, modsDir, packagedModsDir);
+ this.InteractivelyRemoveAppDataMods(modsDir, packagedModsDir);
}
Console.WriteLine();
Console.WriteLine();
@@ -356,20 +427,20 @@ namespace StardewModdingApi.Installer
{
if (action == ScriptAction.Install)
{
- this.PrintColor("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):", ConsoleColor.DarkGreen);
- this.PrintColor($" \"{Path.Combine(installDir.FullName, "StardewModdingAPI.exe")}\" %command%", ConsoleColor.DarkGreen);
+ this.PrintSuccess("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):");
+ this.PrintSuccess($" \"{Path.Combine(installDir.FullName, "StardewModdingAPI.exe")}\" %command%");
Console.WriteLine();
- this.PrintColor("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods.", ConsoleColor.DarkGreen);
+ this.PrintSuccess("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods.");
}
else
- this.PrintColor("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options.", ConsoleColor.DarkGreen);
+ this.PrintSuccess("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options.");
}
else
{
- if (action == ScriptAction.Install)
- this.PrintColor("SMAPI is installed! Launch the game the same way as before to play with mods.", ConsoleColor.DarkGreen);
- else
- this.PrintColor("SMAPI is removed! Launch the game the same way as before to play without mods.", ConsoleColor.DarkGreen);
+ this.PrintSuccess(action == ScriptAction.Install
+ ? "SMAPI is installed! Launch the game the same way as before to play with mods."
+ : "SMAPI is removed! Launch the game the same way as before to play without mods."
+ );
}
Console.ReadKey();
@@ -379,36 +450,17 @@ namespace StardewModdingApi.Installer
/*********
** Private methods
*********/
- /// <summary>Detect the game's platform.</summary>
- /// <exception cref="NotSupportedException">The platform is not supported.</exception>
- private Platform DetectPlatform()
+ /// <summary>Get the display text for an assembly version.</summary>
+ /// <param name="version">The assembly version.</param>
+ private string GetDisplayVersion(Version version)
{
- switch (Environment.OSVersion.Platform)
- {
- case PlatformID.MacOSX:
- case PlatformID.Unix:
- return Platform.Mono;
-
- default:
- return Platform.Windows;
- }
+ string str = $"{version.Major}.{version.Minor}";
+ if (version.Build != 0)
+ str += $".{version.Build}";
+ return str;
}
- /// <summary>Test whether the current console supports color formatting.</summary>
- private static bool GetConsoleSupportsColor()
- {
- try
- {
- Console.ForegroundColor = Console.ForegroundColor;
- return true;
- }
- catch (Exception)
- {
- return false; // Mono bug
- }
- }
-
- /// <summary>Get the value of a key in the Windows registry.</summary>
+ /// <summary>Get the value of a key in the Windows HKLM registry.</summary>
/// <param name="key">The full path of the registry key relative to HKLM.</param>
/// <param name="name">The name of the value.</param>
private string GetLocalMachineRegistryValue(string key, string name)
@@ -421,41 +473,38 @@ namespace StardewModdingApi.Installer
return (string)openKey.GetValue(name);
}
- /// <summary>Print a debug message.</summary>
- /// <param name="text">The text to print.</param>
- private void PrintDebug(string text)
+ /// <summary>Get the value of a key in the Windows HKCU registry.</summary>
+ /// <param name="key">The full path of the registry key relative to HKCU.</param>
+ /// <param name="name">The name of the value.</param>
+ private string GetCurrentUserRegistryValue(string key, string name)
{
- this.PrintColor(text, ConsoleColor.DarkGray);
+ RegistryKey currentuser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser;
+ RegistryKey openKey = currentuser.OpenSubKey(key);
+ if (openKey == null)
+ return null;
+ using (openKey)
+ return (string)openKey.GetValue(name);
}
+ /// <summary>Print a debug message.</summary>
+ /// <param name="text">The text to print.</param>
+ private void PrintDebug(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug);
+
+ /// <summary>Print a debug message.</summary>
+ /// <param name="text">The text to print.</param>
+ private void PrintInfo(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info);
+
/// <summary>Print a warning message.</summary>
/// <param name="text">The text to print.</param>
- private void PrintWarning(string text)
- {
- this.PrintColor(text, ConsoleColor.DarkYellow);
- }
+ private void PrintWarning(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn);
/// <summary>Print a warning message.</summary>
/// <param name="text">The text to print.</param>
- private void PrintError(string text)
- {
- this.PrintColor(text, ConsoleColor.Red);
- }
+ private void PrintError(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error);
- /// <summary>Print a message to the console.</summary>
- /// <param name="text">The message text.</param>
- /// <param name="color">The text foreground color.</param>
- private void PrintColor(string text, ConsoleColor color)
- {
- if (InteractiveInstaller.ConsoleSupportsColor)
- {
- Console.ForegroundColor = color;
- Console.WriteLine(text);
- Console.ResetColor();
- }
- else
- Console.WriteLine(text);
- }
+ /// <summary>Print a success message.</summary>
+ /// <param name="text">The text to print.</param>
+ private void PrintSuccess(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success);
/// <summary>Get whether the current system has .NET Framework 4.5 or later installed. This only applies on Windows.</summary>
/// <param name="platform">The current platform.</param>
@@ -476,7 +525,7 @@ namespace StardewModdingApi.Installer
/// <summary>Get whether the current system has XNA Framework installed. This only applies on Windows.</summary>
/// <param name="platform">The current platform.</param>
/// <exception cref="NotSupportedException">The current platform is not Windows.</exception>
- private bool HasXNA(Platform platform)
+ private bool HasXna(Platform platform)
{
switch (platform)
{
@@ -511,6 +560,7 @@ namespace StardewModdingApi.Installer
/// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary>
/// <param name="entry">The file or folder to reset.</param>
+ /// <remarks>This method is mirred from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks>
private void ForceDelete(FileSystemInfo entry)
{
// ignore if already deleted
@@ -519,8 +569,7 @@ namespace StardewModdingApi.Installer
return;
// delete children
- var folder = entry as DirectoryInfo;
- if (folder != null)
+ if (entry is DirectoryInfo folder)
{
foreach (FileSystemInfo child in folder.GetFileSystemInfos())
this.ForceDelete(child);
@@ -551,11 +600,11 @@ namespace StardewModdingApi.Installer
{
while (true)
{
- Console.WriteLine(message);
+ this.PrintInfo(message);
string input = Console.ReadLine()?.Trim().ToLowerInvariant();
if (!options.Contains(input))
{
- Console.WriteLine("That's not a valid option.");
+ this.PrintInfo("That's not a valid option.");
continue;
}
return input;
@@ -568,9 +617,7 @@ namespace StardewModdingApi.Installer
private DirectoryInfo InteractivelyGetInstallPath(Platform platform, string specifiedPath)
{
// get executable name
- string executableFilename = platform == Platform.Windows
- ? "Stardew Valley.exe"
- : "StardewValley.exe";
+ string executableFilename = EnvironmentUtility.GetExecutableName(platform);
// validate specified path
if (specifiedPath != null)
@@ -597,6 +644,8 @@ namespace StardewModdingApi.Installer
where dir.Exists && dir.EnumerateFiles(executableFilename).Any()
select dir
)
+ .GroupBy(p => p.FullName, StringComparer.InvariantCultureIgnoreCase) // ignore duplicate paths
+ .Select(p => p.First())
.ToArray();
// choose where to install
@@ -608,9 +657,9 @@ namespace StardewModdingApi.Installer
// let user choose path
Console.WriteLine();
- Console.WriteLine("Found multiple copies of the game:");
+ this.PrintInfo("Found multiple copies of the game:");
for (int i = 0; i < defaultPaths.Length; i++)
- Console.WriteLine($"[{i + 1}] {defaultPaths[i].FullName}");
+ this.PrintInfo($"[{i + 1}] {defaultPaths[i].FullName}");
Console.WriteLine();
string[] validOptions = Enumerable.Range(1, defaultPaths.Length).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray();
@@ -620,22 +669,22 @@ namespace StardewModdingApi.Installer
}
// ask user
- Console.WriteLine("Oops, couldn't find the game automatically.");
+ this.PrintInfo("Oops, couldn't find the game automatically.");
while (true)
{
// get path from user
- Console.WriteLine($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter.");
+ this.PrintInfo($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter.");
string path = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(path))
{
- Console.WriteLine(" You must specify a directory path to continue.");
+ this.PrintInfo(" You must specify a directory path to continue.");
continue;
}
// normalise path
if (platform == Platform.Windows)
path = path.Replace("\"", ""); // in Windows, quotes are used to escape spaces and aren't part of the file path
- if (platform == Platform.Mono)
+ if (platform == Platform.Linux || platform == Platform.Mac)
path = path.Replace("\\ ", " "); // in Linux/Mac, spaces in paths may be escaped if copied from the command line
if (path.StartsWith("~/"))
{
@@ -651,35 +700,31 @@ namespace StardewModdingApi.Installer
// validate path
if (!directory.Exists)
{
- Console.WriteLine(" That directory doesn't seem to exist.");
+ this.PrintInfo(" That directory doesn't seem to exist.");
continue;
}
if (!directory.EnumerateFiles(executableFilename).Any())
{
- Console.WriteLine(" That directory doesn't contain a Stardew Valley executable.");
+ this.PrintInfo(" That directory doesn't contain a Stardew Valley executable.");
continue;
}
// looks OK
- Console.WriteLine(" OK!");
+ this.PrintInfo(" OK!");
return directory;
}
}
/// <summary>Interactively move mods out of the appdata directory.</summary>
- /// <param name="platform">The current platform.</param>
/// <param name="properModsDir">The directory which should contain all mods.</param>
/// <param name="packagedModsDir">The installer directory containing packaged mods.</param>
- private void InteractivelyRemoveAppDataMods(Platform platform, DirectoryInfo properModsDir, DirectoryInfo packagedModsDir)
+ private void InteractivelyRemoveAppDataMods(DirectoryInfo properModsDir, DirectoryInfo packagedModsDir)
{
// get packaged mods to delete
string[] packagedModNames = packagedModsDir.GetDirectories().Select(p => p.Name).ToArray();
// get path
- string homePath = platform == Platform.Windows
- ? Environment.GetEnvironmentVariable("APPDATA")
- : Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".config");
- string appDataPath = Path.Combine(homePath, "StardewValley");
+ string appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
DirectoryInfo modDir = new DirectoryInfo(Path.Combine(appDataPath, "Mods"));
// check if migration needed
diff --git a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj
index a575e06f..2ad7e82a 100644
--- a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj
+++ b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj
@@ -9,7 +9,7 @@
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>StardewModdingAPI.Installer</RootNamespace>
<AssemblyName>StardewModdingAPI.Installer</AssemblyName>
- <TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
+ <TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
@@ -40,7 +40,6 @@
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Enums\ScriptAction.cs" />
- <Compile Include="Enums\Platform.cs" />
<Compile Include="InteractiveInstaller.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
@@ -58,7 +57,7 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
- <Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
+ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
<Import Project="..\..\build\prepare-install-package.targets" />
diff --git a/src/SMAPI.Installer/readme.txt b/src/SMAPI.Installer/readme.txt
index a03ad6a4..2ee5473c 100644
--- a/src/SMAPI.Installer/readme.txt
+++ b/src/SMAPI.Installer/readme.txt
@@ -14,15 +14,9 @@
SMAPI lets you run Stardew Valley with mods. Don't forget to download mods separately.
-Install guide
+Player's guide
--------------------------------
-See http://stardewvalleywiki.com/Modding:Installing_SMAPI.
-
-
-Need help?
---------------------------------
-- FAQs: http://stardewvalleywiki.com/Modding:Player_FAQs
-- Ask for help: https://discord.gg/kH55QXP
+See https://stardewvalleywiki.com/Modding:Player_Guide
Manual install
@@ -30,7 +24,7 @@ Manual install
THIS IS NOT RECOMMENDED FOR MOST PLAYERS. See instructions above instead.
If you really want to install SMAPI manually, here's how.
-1. Download the latest version of SMAPI: https://github.com/Pathoschild/SMAPI/releases.
+1. Download the latest version of SMAPI: https://github.com/Pathoschild/SMAPI/releases
2. Unzip the .zip file somewhere (not in your game folder).
3. Copy the files from the "internal/Windows" folder (on Windows) or "internal/Mono" folder (on
Linux/Mac) into your game folder. The `StardewModdingAPI.exe` file should be right next to the
diff --git a/src/SMAPI.Installer/unix-install.sh b/src/SMAPI.Installer/unix-install.sh
index a0bd9346..df02bb37 100644
--- a/src/SMAPI.Installer/unix-install.sh
+++ b/src/SMAPI.Installer/unix-install.sh
@@ -14,7 +14,7 @@ fi
# validate Mono & run installer
if $COMMAND mono >/dev/null 2>&1; then
- mono install.exe
+ mono internal/Mono/install.exe
else
echo "Oops! Looks like Mono isn't installed. Please install Mono from http://mono-project.com, reboot, and run this installer again."
read
diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/unix-launcher.sh
index 6e796461..1e969c20 100644
--- a/src/SMAPI.Installer/unix-launcher.sh
+++ b/src/SMAPI.Installer/unix-launcher.sh
@@ -74,11 +74,11 @@ else
elif $COMMAND xterm 2>/dev/null; then
xterm -e "$LAUNCHER"
elif $COMMAND xfce4-terminal 2>/dev/null; then
- xfce4-terminal -e "$LAUNCHER"
+ xfce4-terminal -e "env TERM=xterm; $LAUNCHER"
elif $COMMAND gnome-terminal 2>/dev/null; then
- gnome-terminal -e "$LAUNCHER"
+ gnome-terminal -e "env TERM=xterm; $LAUNCHER"
elif $COMMAND konsole 2>/dev/null; then
- konsole -e "$LAUNCHER"
+ konsole -p Environment=TERM=xterm -e "$LAUNCHER"
elif $COMMAND terminal 2>/dev/null; then
terminal -e "$LAUNCHER"
else
diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs
new file mode 100644
index 00000000..c04cf0e7
--- /dev/null
+++ b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Internal.ConsoleWriting
+{
+ /// <summary>Provides a wrapper for writing color-coded text to the console.</summary>
+ internal class ColorfulConsoleWriter
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The console text color for each log level.</summary>
+ private readonly IDictionary<ConsoleLogLevel, ConsoleColor> Colors;
+
+ /// <summary>Whether the current console supports color formatting.</summary>
+ private readonly bool SupportsColor;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="platform">The target platform.</param>
+ /// <param name="colorScheme">The console color scheme to use.</param>
+ public ColorfulConsoleWriter(Platform platform, MonitorColorScheme colorScheme)
+ {
+ this.SupportsColor = this.TestColorSupport();
+ this.Colors = this.GetConsoleColorScheme(platform, colorScheme);
+ }
+
+ /// <summary>Write a message line to the log.</summary>
+ /// <param name="message">The message to log.</param>
+ /// <param name="level">The log level.</param>
+ public void WriteLine(string message, ConsoleLogLevel level)
+ {
+ if (this.SupportsColor)
+ {
+ if (level == ConsoleLogLevel.Critical)
+ {
+ Console.BackgroundColor = ConsoleColor.Red;
+ Console.ForegroundColor = ConsoleColor.White;
+ Console.WriteLine(message);
+ Console.ResetColor();
+ }
+ else
+ {
+ Console.ForegroundColor = this.Colors[level];
+ Console.WriteLine(message);
+ Console.ResetColor();
+ }
+ }
+ else
+ Console.WriteLine(message);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Test whether the current console supports color formatting.</summary>
+ private bool TestColorSupport()
+ {
+ try
+ {
+ Console.ForegroundColor = Console.ForegroundColor;
+ return true;
+ }
+ catch (Exception)
+ {
+ return false; // Mono bug
+ }
+ }
+
+ /// <summary>Get the color scheme to use for the current console.</summary>
+ /// <param name="platform">The target platform.</param>
+ /// <param name="colorScheme">The console color scheme to use.</param>
+ private IDictionary<ConsoleLogLevel, ConsoleColor> GetConsoleColorScheme(Platform platform, MonitorColorScheme colorScheme)
+ {
+ // auto detect color scheme
+ if (colorScheme == MonitorColorScheme.AutoDetect)
+ {
+ colorScheme = platform == Platform.Mac
+ ? MonitorColorScheme.LightBackground // MacOS doesn't provide console background color info, but it's usually white.
+ : ColorfulConsoleWriter.IsDark(Console.BackgroundColor) ? MonitorColorScheme.DarkBackground : MonitorColorScheme.LightBackground;
+ }
+
+ // get colors for scheme
+ switch (colorScheme)
+ {
+ case MonitorColorScheme.DarkBackground:
+ return new Dictionary<ConsoleLogLevel, ConsoleColor>
+ {
+ [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray,
+ [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray,
+ [ConsoleLogLevel.Info] = ConsoleColor.White,
+ [ConsoleLogLevel.Warn] = ConsoleColor.Yellow,
+ [ConsoleLogLevel.Error] = ConsoleColor.Red,
+ [ConsoleLogLevel.Alert] = ConsoleColor.Magenta,
+ [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen
+ };
+
+ case MonitorColorScheme.LightBackground:
+ return new Dictionary<ConsoleLogLevel, ConsoleColor>
+ {
+ [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray,
+ [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray,
+ [ConsoleLogLevel.Info] = ConsoleColor.Black,
+ [ConsoleLogLevel.Warn] = ConsoleColor.DarkYellow,
+ [ConsoleLogLevel.Error] = ConsoleColor.Red,
+ [ConsoleLogLevel.Alert] = ConsoleColor.DarkMagenta,
+ [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen
+ };
+
+ default:
+ throw new NotSupportedException($"Unknown color scheme '{colorScheme}'.");
+ }
+ }
+
+ /// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary>
+ /// <param name="color">The color to check.</param>
+ private static bool IsDark(ConsoleColor color)
+ {
+ switch (color)
+ {
+ case ConsoleColor.Black:
+ case ConsoleColor.Blue:
+ case ConsoleColor.DarkBlue:
+ case ConsoleColor.DarkMagenta: // Powershell
+ case ConsoleColor.DarkRed:
+ case ConsoleColor.Red:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Internal/ConsoleWriting/LogLevel.cs b/src/SMAPI.Internal/ConsoleWriting/LogLevel.cs
new file mode 100644
index 00000000..54564111
--- /dev/null
+++ b/src/SMAPI.Internal/ConsoleWriting/LogLevel.cs
@@ -0,0 +1,30 @@
+namespace StardewModdingAPI.Internal.ConsoleWriting
+{
+ /// <summary>The log severity levels.</summary>
+ internal enum ConsoleLogLevel
+ {
+ /// <summary>Tracing info intended for developers.</summary>
+ Trace,
+
+ /// <summary>Troubleshooting info that may be relevant to the player.</summary>
+ Debug,
+
+ /// <summary>Info relevant to the player. This should be used judiciously.</summary>
+ Info,
+
+ /// <summary>An issue the player should be aware of. This should be used rarely.</summary>
+ Warn,
+
+ /// <summary>A message indicating something went wrong.</summary>
+ Error,
+
+ /// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary>
+ Alert,
+
+ /// <summary>A critical issue that generally signals an immediate end to the application.</summary>
+ Critical,
+
+ /// <summary>A success message that generally signals a successful end to a task.</summary>
+ Success
+ }
+}
diff --git a/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs b/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs
new file mode 100644
index 00000000..bccb56d7
--- /dev/null
+++ b/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Internal.ConsoleWriting
+{
+ /// <summary>A monitor color scheme to use.</summary>
+ internal enum MonitorColorScheme
+ {
+ /// <summary>Choose a color scheme automatically.</summary>
+ AutoDetect,
+
+ /// <summary>Use lighter text colors that look better on a black or dark background.</summary>
+ DarkBackground,
+
+ /// <summary>Use darker text colors that look better on a white or light background.</summary>
+ LightBackground
+ }
+}
diff --git a/src/SMAPI.Internal/EnvironmentUtility.cs b/src/SMAPI.Internal/EnvironmentUtility.cs
new file mode 100644
index 00000000..a3581898
--- /dev/null
+++ b/src/SMAPI.Internal/EnvironmentUtility.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+#if SMAPI_FOR_WINDOWS
+using System.Management;
+#endif
+using System.Runtime.InteropServices;
+
+namespace StardewModdingAPI.Internal
+{
+ /// <summary>Provides methods for fetching environment information.</summary>
+ internal static class EnvironmentUtility
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Get the OS name from the system uname command.</summary>
+ /// <param name="buffer">The buffer to fill with the resulting string.</param>
+ [DllImport("libc")]
+ static extern int uname(IntPtr buffer);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Detect the current OS.</summary>
+ public static Platform DetectPlatform()
+ {
+ switch (Environment.OSVersion.Platform)
+ {
+ case PlatformID.MacOSX:
+ return Platform.Mac;
+
+ case PlatformID.Unix:
+ return EnvironmentUtility.IsRunningMac()
+ ? Platform.Mac
+ : Platform.Linux;
+
+ default:
+ return Platform.Windows;
+ }
+ }
+
+
+ /// <summary>Get the human-readable OS name and version.</summary>
+ /// <param name="platform">The current platform.</param>
+ [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")]
+ public static string GetFriendlyPlatformName(Platform platform)
+ {
+#if SMAPI_FOR_WINDOWS
+ try
+ {
+ return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem")
+ .Get()
+ .Cast<ManagementObject>()
+ .Select(entry => entry.GetPropertyValue("Caption").ToString())
+ .FirstOrDefault();
+ }
+ catch { }
+#endif
+ return (platform == Platform.Mac ? "MacOS " : "") + Environment.OSVersion;
+ }
+
+ /// <summary>Get the name of the Stardew Valley executable.</summary>
+ /// <param name="platform">The current platform.</param>
+ public static string GetExecutableName(Platform platform)
+ {
+ return platform == Platform.Windows
+ ? "Stardew Valley.exe"
+ : "StardewValley.exe";
+ }
+
+ /// <summary>Get whether the platform uses Mono.</summary>
+ /// <param name="platform">The current platform.</param>
+ public static bool IsMono(this Platform platform)
+ {
+ return platform == Platform.Linux || platform == Platform.Mac;
+ }
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Detect whether the code is running on Mac.</summary>
+ /// <remarks>
+ /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects Mac by calling the
+ /// <c>uname</c> system command and checking the response, which is always 'Darwin' for MacOS.
+ /// </remarks>
+ private static bool IsRunningMac()
+ {
+ IntPtr buffer = IntPtr.Zero;
+ try
+ {
+ buffer = Marshal.AllocHGlobal(8192);
+ if (EnvironmentUtility.uname(buffer) == 0)
+ {
+ string os = Marshal.PtrToStringAnsi(buffer);
+ return os == "Darwin";
+ }
+ return false;
+ }
+ catch
+ {
+ return false; // default to Linux
+ }
+ finally
+ {
+ if (buffer != IntPtr.Zero)
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Internal/Platform.cs b/src/SMAPI.Internal/Platform.cs
new file mode 100644
index 00000000..81ca5c1f
--- /dev/null
+++ b/src/SMAPI.Internal/Platform.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Internal
+{
+ /// <summary>The game's platform version.</summary>
+ internal enum Platform
+ {
+ /// <summary>The Linux version of the game.</summary>
+ Linux,
+
+ /// <summary>The Mac version of the game.</summary>
+ Mac,
+
+ /// <summary>The Windows version of the game.</summary>
+ Windows
+ }
+}
diff --git a/src/SMAPI.Internal/SMAPI.Internal.projitems b/src/SMAPI.Internal/SMAPI.Internal.projitems
new file mode 100644
index 00000000..54b12003
--- /dev/null
+++ b/src/SMAPI.Internal/SMAPI.Internal.projitems
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+ <PropertyGroup>
+ <MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
+ <HasSharedItems>true</HasSharedItems>
+ <SharedGUID>85208f8d-6fd1-4531-be05-7142490f59fe</SharedGUID>
+ </PropertyGroup>
+ <PropertyGroup Label="Configuration">
+ <Import_RootNamespace>SMAPI.Internal</Import_RootNamespace>
+ </PropertyGroup>
+ <ItemGroup>
+ <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ColorfulConsoleWriter.cs" />
+ <Compile Include="$(MSBuildThisFileDirectory)EnvironmentUtility.cs" />
+ <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\LogLevel.cs" />
+ <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\MonitorColorScheme.cs" />
+ <Compile Include="$(MSBuildThisFileDirectory)Platform.cs" />
+ </ItemGroup>
+</Project> \ No newline at end of file
diff --git a/src/SMAPI.Common/StardewModdingAPI.Common.shproj b/src/SMAPI.Internal/StardewModdingAPI.Internal.shproj
index 0ef29144..a098828a 100644
--- a/src/SMAPI.Common/StardewModdingAPI.Common.shproj
+++ b/src/SMAPI.Internal/StardewModdingAPI.Internal.shproj
@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="Globals">
- <ProjectGuid>2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc</ProjectGuid>
+ <ProjectGuid>85208f8d-6fd1-4531-be05-7142490f59fe</ProjectGuid>
<MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" />
<PropertyGroup />
- <Import Project="StardewModdingAPI.Common.projitems" Label="Shared" />
+ <Import Project="SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" />
</Project>
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs
new file mode 100644
index 00000000..d160610e
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs
@@ -0,0 +1,10 @@
+// ReSharper disable CheckNamespace -- matches Stardew Valley's code
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Netcode
+{
+ /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetCollection</c> for unit testing.</summary>
+ public class NetCollection<T> : Collection<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs
new file mode 100644
index 00000000..1699f71c
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs
@@ -0,0 +1,9 @@
+// ReSharper disable CheckNamespace -- matches Stardew Valley's code
+using System.Collections;
+using System.Collections.Generic;
+
+namespace Netcode
+{
+ /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetObjectList</c> for unit testing.</summary>
+ public class NetList<T> : List<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs
new file mode 100644
index 00000000..7814e7d6
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetObjectList.cs
@@ -0,0 +1,6 @@
+// ReSharper disable CheckNamespace -- matches Stardew Valley's code
+namespace Netcode
+{
+ /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetObjectList</c> for unit testing.</summary>
+ public class NetObjectList<T> : NetList<T> { }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs
index e0f0e30c..13fab069 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs
@@ -1,4 +1,5 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
+#pragma warning disable 649 // (never assigned) -- only used to test type conversions
using System.Collections.Generic;
namespace StardewValley
@@ -6,6 +7,7 @@ namespace StardewValley
/// <summary>A simplified version of Stardew Valley's <c>StardewValley.Farmer</c> class for unit testing.</summary>
internal class Farmer
{
- public IDictionary<string, int[]> friendships;
+ /// <summary>A sample field which should be replaced with a different property.</summary>
+ public readonly IDictionary<string, int[]> friendships;
}
}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs
index 386767d7..1b6317c1 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs
@@ -20,5 +20,14 @@ namespace StardewValley
/// <summary>A generic net ref property with no equivalent non-net property.</summary>
public NetRef<object> netRefProperty { get; } = new NetRef<object>();
+
+ /// <summary>A sample net list.</summary>
+ public readonly NetList<int> netList = new NetList<int>();
+
+ /// <summary>A sample net object list.</summary>
+ public readonly NetObjectList<int> netObjectList = new NetObjectList<int>();
+
+ /// <summary>A sample net collection.</summary>
+ public readonly NetCollection<int> netCollection = new NetCollection<int>();
}
}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
index 101f4c21..6f8c8b9b 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
@@ -59,7 +59,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
/// <param name="expression">The expression which should be reported.</param>
/// <param name="fromType">The source type name which should be reported.</param>
/// <param name="toType">The target type name which should be reported.</param>
- [TestCase("Item item = null; if (item.netIntField < 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntField < 42);", 22, "item.netIntField", "NetInt", "int")] // ↓ implicit conversion
[TestCase("Item item = null; if (item.netIntField <= 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField > 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField >= 42);", 22, "item.netIntField", "NetInt", "int")]
@@ -79,20 +79,24 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
[TestCase("Item item = null; if (item.netRefField != null);", 22, "item.netRefField", "NetRef", "object")]
[TestCase("Item item = null; if (item.netRefProperty == null);", 22, "item.netRefProperty", "NetRef", "object")]
[TestCase("Item item = null; if (item.netRefProperty != null);", 22, "item.netRefProperty", "NetRef", "object")]
- [TestCase("SObject obj = null; if (obj.netIntField != 42);", 24, "obj.netIntField", "NetInt", "int")] // ↓ same as above, but inherited from base class
+ [TestCase("SObject obj = null; if (obj.netIntField != 42);", 24, "obj.netIntField", "NetInt", "int")] // ↓ implicit conversion for parent field
[TestCase("SObject obj = null; if (obj.netIntProperty != 42);", 24, "obj.netIntProperty", "NetInt", "int")]
[TestCase("SObject obj = null; if (obj.netRefField == null);", 24, "obj.netRefField", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netRefField != null);", 24, "obj.netRefField", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netRefProperty == null);", 24, "obj.netRefProperty", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")]
+ [TestCase("Item item = new Item(); object list = item.netList;", 38, "item.netList", "NetList", "object")] // ↓ NetList field converted to a non-interface type
+ [TestCase("Item item = new Item(); object list = item.netCollection;", 38, "item.netCollection", "NetCollection", "object")]
+ [TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetInt", "int")] // ↓ explicit conversion to invalid type
+ [TestCase("Item item = new Item(); int x = item.netRefField as object;", 32, "item.netRefField", "NetRef", "object")]
public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType)
{
// arrange
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
{
- Id = "SMAPI001",
- Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/smapi001 for details.",
+ Id = "AvoidImplicitNetFieldCast",
+ Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/avoid-implicit-net-field-cast for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
};
@@ -101,6 +105,22 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
this.VerifyCSharpDiagnostic(code, expected);
}
+ /// <summary>Test that the net field analyzer doesn't raise any warnings for safe member access.</summary>
+ /// <param name="codeText">The code line to test.</param>
+ [TestCase("Item item = new Item(); System.Collections.IEnumerable list = farmer.eventsSeen;")]
+ [TestCase("Item item = new Item(); System.Collections.Generic.IEnumerable<int> list = farmer.netList;")]
+ [TestCase("Item item = new Item(); System.Collections.Generic.IList<int> list = farmer.netList;")]
+ [TestCase("Item item = new Item(); System.Collections.Generic.ICollection<int> list = farmer.netCollection;")]
+ [TestCase("Item item = new Item(); System.Collections.Generic.IList<int> list = farmer.netObjectList;")] // subclass of NetList
+ public void AvoidImplicitNetFieldComparisons_AllowsSafeAccess(string codeText)
+ {
+ // arrange
+ string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
+
+ // assert
+ this.VerifyCSharpDiagnostic(code);
+ }
+
/// <summary>Test that the expected diagnostic message is raised for avoidable net field references.</summary>
/// <param name="codeText">The code line to test.</param>
/// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
@@ -117,8 +137,8 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
{
- Id = "SMAPI002",
- Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/buildmsg/smapi002 for details.",
+ Id = "AvoidNetField",
+ Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/buildmsg/avoid-net-field for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
};
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs
index dc7476ef..102a80d1 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs
@@ -59,14 +59,15 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
/// <param name="oldName">The old field name which should be reported.</param>
/// <param name="newName">The new field name which should be reported.</param>
[TestCase("var x = new Farmer().friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")]
+ [TestCase("var x = new Farmer()?.friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")]
public void AvoidObsoleteField_RaisesDiagnostic(string codeText, int column, string oldName, string newName)
{
// arrange
string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
{
- Id = "SMAPI003",
- Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/buildmsg/smapi003 for details.",
+ Id = "AvoidObsoleteField",
+ Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/buildmsg/avoid-obsolete-field for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) }
};
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
index b5630314..c6241ecb 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" />
- <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.2" />
<PackageReference Include="NUnit" Version="3.10.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.10.0" />
</ItemGroup>
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs
new file mode 100644
index 00000000..68b5001e
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs
@@ -0,0 +1,93 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace StardewModdingAPI.ModBuildConfig.Analyzer
+{
+ /// <summary>Provides generic utilities for SMAPI's Roslyn analyzers.</summary>
+ internal static class AnalyzerUtilities
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get the metadata for an explicit cast or 'x as y' expression.</summary>
+ /// <param name="node">The member access expression.</param>
+ /// <param name="semanticModel">provides methods for asking semantic questions about syntax nodes.</param>
+ /// <param name="fromExpression">The expression whose value is being converted.</param>
+ /// <param name="fromType">The type being converted from.</param>
+ /// <param name="toType">The type being converted to.</param>
+ /// <returns>Returns true if the node is a matched expression, else false.</returns>
+ public static bool TryGetCastOrAsInfo(SyntaxNode node, SemanticModel semanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType)
+ {
+ // (type)x
+ if (node is CastExpressionSyntax cast)
+ {
+ fromExpression = cast.Expression;
+ fromType = semanticModel.GetTypeInfo(fromExpression);
+ toType = semanticModel.GetTypeInfo(cast.Type);
+ return true;
+ }
+
+ // x as y
+ if (node is BinaryExpressionSyntax binary && binary.Kind() == SyntaxKind.AsExpression)
+ {
+ fromExpression = binary.Left;
+ fromType = semanticModel.GetTypeInfo(fromExpression);
+ toType = semanticModel.GetTypeInfo(binary.Right);
+ return true;
+ }
+
+ // invalid
+ fromExpression = null;
+ fromType = default(TypeInfo);
+ toType = default(TypeInfo);
+ return false;
+ }
+
+ /// <summary>Get the metadata for a member access expression.</summary>
+ /// <param name="node">The member access expression.</param>
+ /// <param name="semanticModel">provides methods for asking semantic questions about syntax nodes.</param>
+ /// <param name="declaringType">The object type which has the member.</param>
+ /// <param name="memberType">The type of the accessed member.</param>
+ /// <param name="memberName">The name of the accessed member.</param>
+ /// <returns>Returns true if the node is a member access expression, else false.</returns>
+ public static bool TryGetMemberInfo(SyntaxNode node, SemanticModel semanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName)
+ {
+ // simple access
+ if (node is MemberAccessExpressionSyntax memberAccess)
+ {
+ declaringType = semanticModel.GetTypeInfo(memberAccess.Expression).Type;
+ memberType = semanticModel.GetTypeInfo(node);
+ memberName = memberAccess.Name.Identifier.Text;
+ return true;
+ }
+
+ // conditional access
+ if (node is ConditionalAccessExpressionSyntax conditionalAccess && conditionalAccess.WhenNotNull is MemberBindingExpressionSyntax conditionalBinding)
+ {
+ declaringType = semanticModel.GetTypeInfo(conditionalAccess.Expression).Type;
+ memberType = semanticModel.GetTypeInfo(node);
+ memberName = conditionalBinding.Name.Identifier.Text;
+ return true;
+ }
+
+ // invalid
+ declaringType = null;
+ memberType = default(TypeInfo);
+ memberName = null;
+ return false;
+ }
+
+ /// <summary>Get the class types in a type's inheritance chain, including itself.</summary>
+ /// <param name="type">The initial type.</param>
+ public static IEnumerable<ITypeSymbol> GetConcreteTypes(ITypeSymbol type)
+ {
+ while (type != null)
+ {
+ yield return type;
+ type = type.BaseType;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs
index 915a50e8..e6766e61 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
+using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -21,6 +22,13 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <summary>Maps net fields to their equivalent non-net properties where available.</summary>
private readonly IDictionary<string, string> NetFieldWrapperProperties = new Dictionary<string, string>
{
+ // AnimatedSprite
+ ["StardewValley.AnimatedSprite::currentAnimation"] = "CurrentAnimation",
+ ["StardewValley.AnimatedSprite::currentFrame"] = "CurrentFrame",
+ ["StardewValley.AnimatedSprite::sourceRect"] = "SourceRect",
+ ["StardewValley.AnimatedSprite::spriteHeight"] = "SpriteHeight",
+ ["StardewValley.AnimatedSprite::spriteWidth"] = "SpriteWidth",
+
// Character
["StardewValley.Character::currentLocationRef"] = "currentLocation",
["StardewValley.Character::facingDirection"] = "FacingDirection",
@@ -106,7 +114,6 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
["StardewValley.Object::netName"] = "name",
["StardewValley.Object::price"] = "Price",
["StardewValley.Object::quality"] = "Quality",
- ["StardewValley.Object::scale"] = "Scale",
["StardewValley.Object::stack"] = "Stack",
["StardewValley.Object::tileLocation"] = "TileLocation",
["StardewValley.Object::type"] = "Type",
@@ -124,28 +131,27 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
["StardewValley.Tool::upgradeLevel"] = "UpgradeLevel"
};
- /// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
- private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
- {
- ["SMAPI001"] = new DiagnosticDescriptor(
- id: "SMAPI001",
- title: "Netcode types shouldn't be implicitly converted",
- messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/smapi001 for details.",
- category: "SMAPI.CommonErrors",
- defaultSeverity: DiagnosticSeverity.Warning,
- isEnabledByDefault: true,
- helpLinkUri: "https://smapi.io/buildmsg/smapi001"
- ),
- ["SMAPI002"] = new DiagnosticDescriptor(
- id: "SMAPI002",
- title: "Avoid Netcode types when possible",
- messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/buildmsg/smapi002 for details.",
- category: "SMAPI.CommonErrors",
- defaultSeverity: DiagnosticSeverity.Warning,
- isEnabledByDefault: true,
- helpLinkUri: "https://smapi.io/buildmsg/smapi001"
- )
- };
+ /// <summary>The diagnostic info for an implicit net field cast.</summary>
+ private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new DiagnosticDescriptor(
+ id: "AvoidImplicitNetFieldCast",
+ title: "Netcode types shouldn't be implicitly converted",
+ messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/avoid-implicit-net-field-cast for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/buildmsg/avoid-implicit-net-field-cast"
+ );
+
+ /// <summary>The diagnostic info for an avoidable net field access.</summary>
+ private readonly DiagnosticDescriptor AvoidNetFieldRule = new DiagnosticDescriptor(
+ id: "AvoidNetField",
+ title: "Avoid Netcode types when possible",
+ messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/buildmsg/avoid-net-field for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/buildmsg/avoid-net-field"
+ );
/*********
@@ -161,22 +167,25 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <summary>Construct an instance.</summary>
public NetFieldAnalyzer()
{
- this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values);
+ this.SupportedDiagnostics = ImmutableArray.CreateRange(new[] { this.AvoidNetFieldRule, this.AvoidImplicitNetFieldCastRule });
}
/// <summary>Called once at session start to register actions in the analysis context.</summary>
/// <param name="context">The analysis context.</param>
public override void Initialize(AnalysisContext context)
{
- // SMAPI002: avoid net fields if possible
context.RegisterSyntaxNodeAction(
- this.AnalyzeAvoidableNetField,
- SyntaxKind.SimpleMemberAccessExpression
+ this.AnalyzeMemberAccess,
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxKind.ConditionalAccessExpression
+ );
+ context.RegisterSyntaxNodeAction(
+ this.AnalyzeCast,
+ SyntaxKind.CastExpression,
+ SyntaxKind.AsExpression
);
-
- // SMAPI001: avoid implicit net field conversion
context.RegisterSyntaxNodeAction(
- this.AnalyseNetFieldConversions,
+ this.AnalyzeBinaryComparison,
SyntaxKind.EqualsExpression,
SyntaxKind.NotEqualsExpression,
SyntaxKind.GreaterThanExpression,
@@ -190,77 +199,126 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/*********
** Private methods
*********/
- /// <summary>Analyse a syntax node and add a diagnostic message if it references a net field when there's a non-net equivalent available.</summary>
+ /// <summary>Analyse a member access syntax node and add a diagnostic message if applicable.</summary>
/// <param name="context">The analysis context.</param>
- private void AnalyzeAvoidableNetField(SyntaxNodeAnalysisContext context)
+ /// <returns>Returns whether any warnings were added.</returns>
+ private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
{
- try
+ this.HandleErrors(context.Node, () =>
{
- // check member type
- MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
- TypeInfo memberType = context.SemanticModel.GetTypeInfo(node);
+ // get member access info
+ if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName))
+ return;
if (!this.IsNetType(memberType.Type))
return;
- // get reference info
- ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
- string propertyName = node.Name.Identifier.Text;
-
- // suggest replacement
- for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
+ // warn: use property wrapper if available
+ foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType))
{
- if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{propertyName}", out string suggestedPropertyName))
+ if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{memberName}", out string suggestedPropertyName))
{
- context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI002"], context.Node.GetLocation(), node, memberType.Type.Name, suggestedPropertyName));
- break;
+ context.ReportDiagnostic(Diagnostic.Create(this.AvoidNetFieldRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, suggestedPropertyName));
+ return;
}
}
- }
- catch (Exception ex)
+
+ // warn: implicit conversion
+ if (this.IsInvalidConversion(memberType.Type, memberType.ConvertedType))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, memberType.ConvertedType));
+ return;
+ }
+ });
+ }
+
+ /// <summary>Analyse an explicit cast or 'x as y' node and add a diagnostic message if applicable.</summary>
+ /// <param name="context">The analysis context.</param>
+ /// <returns>Returns whether any warnings were added.</returns>
+ private void AnalyzeCast(SyntaxNodeAnalysisContext context)
+ {
+ // NOTE: implicit conversion within the expression is detected by the member access
+ // checks. This method is only concerned with the conversion of its final value.
+ this.HandleErrors(context.Node, () =>
{
- throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
- }
+ if (AnalyzerUtilities.TryGetCastOrAsInfo(context.Node, context.SemanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType))
+ {
+ if (this.IsInvalidConversion(fromType.ConvertedType, toType.Type))
+ context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), fromExpression, fromType.ConvertedType.Name, toType.Type));
+ }
+ });
}
- /// <summary>Analyse a syntax node and add a diagnostic message if it implicitly converts a net field.</summary>
+ /// <summary>Analyse a binary comparison syntax node and add a diagnostic message if applicable.</summary>
/// <param name="context">The analysis context.</param>
- private void AnalyseNetFieldConversions(SyntaxNodeAnalysisContext context)
+ /// <returns>Returns whether any warnings were added.</returns>
+ private void AnalyzeBinaryComparison(SyntaxNodeAnalysisContext context)
{
- try
+ // NOTE: implicit conversion within an operand is detected by the member access checks.
+ // This method is only concerned with the conversion of each side's final value.
+ this.HandleErrors(context.Node, () =>
{
- BinaryExpressionSyntax binaryExpression = (BinaryExpressionSyntax)context.Node;
- foreach (var pair in new[] { Tuple.Create(binaryExpression.Left, binaryExpression.Right), Tuple.Create(binaryExpression.Right, binaryExpression.Left) })
+ BinaryExpressionSyntax expression = (BinaryExpressionSyntax)context.Node;
+ foreach (var pair in new[] { Tuple.Create(expression.Left, expression.Right), Tuple.Create(expression.Right, expression.Left) })
{
// get node info
ExpressionSyntax curExpression = pair.Item1; // the side of the comparison being examined
ExpressionSyntax otherExpression = pair.Item2; // the other side
- TypeInfo typeInfo = context.SemanticModel.GetTypeInfo(curExpression);
- if (!this.IsNetType(typeInfo.Type))
+ TypeInfo curType = context.SemanticModel.GetTypeInfo(curExpression);
+ TypeInfo otherType = context.SemanticModel.GetTypeInfo(otherExpression);
+ if (!this.IsNetType(curType.ConvertedType))
continue;
- // warn for implicit conversion
- if (!this.IsNetType(typeInfo.ConvertedType))
- {
- context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI001"], context.Node.GetLocation(), curExpression, typeInfo.Type.Name, typeInfo.ConvertedType));
- break;
- }
-
// warn for comparison to null
// An expression like `building.indoors != null` will sometimes convert `building.indoors` to NetFieldBase instead of object before comparison. Haven't reproduced this in unit tests yet.
Optional<object> otherValue = context.SemanticModel.GetConstantValue(otherExpression);
if (otherValue.HasValue && otherValue.Value == null)
{
- context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI001"], context.Node.GetLocation(), curExpression, typeInfo.Type.Name, "null"));
+ context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, "null"));
+ break;
+ }
+
+ // warn for implicit conversion
+ if (!this.IsNetType(otherType.ConvertedType))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, curType.ConvertedType));
break;
}
}
+ });
+ }
+
+ /// <summary>Handle exceptions raised while analyzing a node.</summary>
+ /// <param name="node">The node being analysed.</param>
+ /// <param name="action">The callback to invoke.</param>
+ private void HandleErrors(SyntaxNode node, Action action)
+ {
+ try
+ {
+ action();
}
catch (Exception ex)
{
- throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
+ throw new InvalidOperationException($"Failed processing expression: '{node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
}
}
+ /// <summary>Get whether a net field was converted in an error-prone way.</summary>
+ /// <param name="fromType">The source type.</param>
+ /// <param name="toType">The target type.</param>
+ private bool IsInvalidConversion(ITypeSymbol fromType, ITypeSymbol toType)
+ {
+ // no conversion
+ if (!this.IsNetType(fromType) || this.IsNetType(toType))
+ return false;
+
+ // conversion to implemented interface is OK
+ if (fromType.AllInterfaces.Contains(toType))
+ return false;
+
+ // avoid any other conversions
+ return true;
+ }
+
/// <summary>Get whether a type symbol references a <c>Netcode</c> type.</summary>
/// <param name="typeSymbol">The type symbol.</param>
private bool IsNetType(ITypeSymbol typeSymbol)
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs
index 00565329..3d353e52 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs
+++ b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace StardewModdingAPI.ModBuildConfig.Analyzer
@@ -25,14 +24,14 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
{
- ["SMAPI003"] = new DiagnosticDescriptor(
- id: "SMAPI003",
+ ["AvoidObsoleteField"] = new DiagnosticDescriptor(
+ id: "AvoidObsoleteField",
title: "Reference to obsolete field",
- messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/smapi003 for details.",
+ messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/avoid-obsolete-field for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
- helpLinkUri: "https://smapi.io/buildmsg/smapi003"
+ helpLinkUri: "https://smapi.io/buildmsg/avoid-obsolete-field"
)
};
@@ -57,10 +56,10 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <param name="context">The analysis context.</param>
public override void Initialize(AnalysisContext context)
{
- // SMAPI003: avoid obsolete fields
context.RegisterSyntaxNodeAction(
this.AnalyzeObsoleteFields,
- SyntaxKind.SimpleMemberAccessExpression
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxKind.ConditionalAccessExpression
);
}
@@ -75,16 +74,15 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
try
{
// get reference info
- MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
- ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
- string propertyName = node.Name.Identifier.Text;
+ if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName))
+ return;
// suggest replacement
- for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
+ foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType))
{
- if (this.ReplacedFields.TryGetValue($"{type}::{propertyName}", out string replacement))
+ if (this.ReplacedFields.TryGetValue($"{type}::{memberName}", out string replacement))
{
- context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI003"], context.Node.GetLocation(), $"{type}.{propertyName}", replacement));
+ context.ReportDiagnostic(Diagnostic.Create(this.Rules["AvoidObsoleteField"], context.Node.GetLocation(), $"{type}.{memberName}", replacement));
break;
}
}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj b/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj
index c32343e3..9d3f6d5b 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj
+++ b/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj
@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" PrivateAssets="all" />
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" PrivateAssets="all" />
<PackageReference Update="NETStandard.Library" PrivateAssets="all" />
</ItemGroup>
diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs
index a5725a81..96d95e06 100644
--- a/src/SMAPI.ModBuildConfig/DeployModTask.cs
+++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs
@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
+using System.Linq;
+using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using StardewModdingAPI.ModBuildConfig.Framework;
@@ -42,6 +44,9 @@ namespace StardewModdingAPI.ModBuildConfig
[Required]
public bool EnableModZip { get; set; }
+ /// <summary>Custom comma-separated regex patterns matching files to ignore when deploying or zipping the mod.</summary>
+ public string IgnoreModFilePatterns { get; set; }
+
/*********
** Public methods
@@ -55,8 +60,11 @@ namespace StardewModdingAPI.ModBuildConfig
try
{
+ // parse ignore patterns
+ Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray();
+
// get mod info
- ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir);
+ ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePatterns, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip);
// deploy mod files
if (this.EnableModDeploy)
@@ -91,6 +99,29 @@ namespace StardewModdingAPI.ModBuildConfig
/*********
** Private methods
*********/
+ /// <summary>Get the custom ignore patterns provided by the user.</summary>
+ private IEnumerable<Regex> GetCustomIgnorePatterns()
+ {
+ if (string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns))
+ yield break;
+
+ foreach (string raw in this.IgnoreModFilePatterns.Split(','))
+ {
+ Regex regex;
+ try
+ {
+ regex = new Regex(raw.Trim(), RegexOptions.IgnoreCase);
+ }
+ catch (Exception ex)
+ {
+ this.Log.LogWarning($"Ignored invalid <{nameof(this.IgnoreModFilePatterns)}> pattern {raw}:\n{ex}");
+ continue;
+ }
+
+ yield return regex;
+ }
+ }
+
/// <summary>Copy the mod files into the game's mod folder.</summary>
/// <param name="files">The files to include.</param>
/// <param name="modFolderPath">The folder path to create with the mod files.</param>
diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
index 64262dc2..f4738d71 100644
--- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
+++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs
@@ -2,8 +2,9 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Web.Script.Serialization;
-using StardewModdingAPI.Common;
+using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.ModBuildConfig.Framework
{
@@ -26,8 +27,10 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
/// <summary>Construct an instance.</summary>
/// <param name="projectDir">The folder containing the project files.</param>
/// <param name="targetDir">The folder containing the build output.</param>
+ /// <param name="ignoreFilePatterns">Custom regex patterns matching files to ignore when deploying or zipping the mod.</param>
+ /// <param name="validateRequiredModFiles">Whether to validate that required mod files like the manifest are present.</param>
/// <exception cref="UserErrorException">The mod package isn't valid.</exception>
- public ModFileManager(string projectDir, string targetDir)
+ public ModFileManager(string projectDir, string targetDir, Regex[] ignoreFilePatterns, bool validateRequiredModFiles)
{
this.Files = new Dictionary<string, FileInfo>(StringComparer.InvariantCultureIgnoreCase);
@@ -72,26 +75,26 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
if (hasProjectTranslations && this.EqualsInvariant(relativeDirPath, "i18n"))
continue;
- // ignore release zips
- if (this.EqualsInvariant(file.Extension, ".zip"))
- continue;
-
- // ignore Json.NET (bundled into SMAPI)
- if (this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll") || this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml"))
+ // handle ignored files
+ if (this.ShouldIgnore(file, relativePath, ignoreFilePatterns))
continue;
// add file
this.Files[relativePath] = file;
}
- // check for missing manifest
- if (!this.Files.ContainsKey(this.ManifestFileName))
- throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output.");
-
- // check for missing DLL
- // ReSharper disable once SimplifyLinqExpression
- if (!this.Files.Any(p => !p.Key.EndsWith(".dll")))
- throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output.");
+ // check for required files
+ if (validateRequiredModFiles)
+ {
+ // manifest
+ if (!this.Files.ContainsKey(this.ManifestFileName))
+ throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output.");
+
+ // DLL
+ // ReSharper disable once SimplifyLinqExpression
+ if (!this.Files.Any(p => !p.Key.EndsWith(".dll")))
+ throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output.");
+ }
}
/// <summary>Get the files in the mod package.</summary>
@@ -136,15 +139,41 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0;
int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0;
string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null;
- return new SemanticVersionImpl(major, minor, patch, tag).ToString();
+ return new SemanticVersion(major, minor, patch, tag).ToString();
}
- return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+
+ return new SemanticVersion(versionObj.ToString()).ToString(); // SMAPI 2.0+
}
/*********
** Private methods
*********/
+ /// <summary>Get whether a build output file should be ignored.</summary>
+ /// <param name="file">The file to check.</param>
+ /// <param name="relativePath">The file's relative path in the package.</param>
+ /// <param name="ignoreFilePatterns">Custom regex patterns matching files to ignore when deploying or zipping the mod.</param>
+ private bool ShouldIgnore(FileInfo file, string relativePath, Regex[] ignoreFilePatterns)
+ {
+ return
+ // release zips
+ this.EqualsInvariant(file.Extension, ".zip")
+
+ // Json.NET (bundled into SMAPI)
+ || this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll")
+ || this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml")
+
+ // code analysis files
+ || file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.InvariantCultureIgnoreCase)
+ || file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.InvariantCultureIgnoreCase)
+
+ // OS metadata files
+ || this.EqualsInvariant(file.Name, ".DS_Store")
+ || this.EqualsInvariant(file.Name, "Thumbs.db")
+
+ // custom ignore patterns
+ || ignoreFilePatterns.Any(p => p.IsMatch(relativePath));
+ }
+
/// <summary>Get a case-insensitive dictionary matching the given JSON.</summary>
/// <param name="json">The JSON to parse.</param>
private IDictionary<string, object> Parse(string json)
diff --git a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs
index 96bd29f4..d6f8dd7f 100644
--- a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs
+++ b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs
@@ -2,5 +2,5 @@ using System.Reflection;
[assembly: AssemblyTitle("SMAPI.ModBuildConfig")]
[assembly: AssemblyDescription("")]
-[assembly: AssemblyVersion("2.0.2.0")]
-[assembly: AssemblyFileVersion("2.0.2.0")]
+[assembly: AssemblyVersion("2.1.0.0")]
+[assembly: AssemblyFileVersion("2.1.0.0")]
diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
index 2e3ba356..6a52daac 100644
--- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
+++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
@@ -55,7 +55,14 @@
<ItemGroup>
<Content Include="assets\nuget-icon.png" />
</ItemGroup>
- <Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
+ <ItemGroup>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj">
+ <Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project>
+ <Name>StardewModdingAPI.Toolkit</Name>
+ </ProjectReference>
+ </ItemGroup>
+ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
+ <Import Project="..\..\build\common.targets" />
<Import Project="..\..\build\prepare-nuget-package.targets" />
</Project> \ No newline at end of file
diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets
index a177840c..d1c8a4eb 100644
--- a/src/SMAPI.ModBuildConfig/build/smapi.targets
+++ b/src/SMAPI.ModBuildConfig/build/smapi.targets
@@ -19,14 +19,10 @@
<!-- set default settings -->
<ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName>
- <ModUnitTests Condition="'$(ModUnitTests)' == ''">False</ModUnitTests>
<ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath>
<EnableModDeploy Condition="'$(EnableModDeploy)' == ''">True</EnableModDeploy>
<EnableModZip Condition="'$(EnableModZip)' == ''">True</EnableModZip>
-
- <!-- disable mod deploy in unit test project -->
- <EnableModDeploy Condition="'$(ModUnitTests)' == true">False</EnableModDeploy>
- <EnableModZip Condition="'$(ModUnitTests)' == true">False</EnableModZip>
+ <CopyModReferencesToBuildOutput Condition="'$(CopyModReferencesToBuildOutput)' == ''">False</CopyModReferencesToBuildOutput>
</PropertyGroup>
<!-- find platform + game path -->
@@ -35,6 +31,7 @@
<PropertyGroup>
<!-- Linux -->
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/GOG Games/Stardew Valley/game</GamePath>
+ <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.steam/steam/steamapps/common/Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.local/share/Steam/steamapps/common/Stardew Valley</GamePath>
<!-- Mac (may be 'Unix' or 'OSX') -->
@@ -44,10 +41,18 @@
</When>
<When Condition="$(OS) == 'Windows_NT'">
<PropertyGroup>
+ <!-- default paths -->
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath>
+ <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath>
+
+ <!-- registry paths -->
<GamePath Condition="!Exists('$(GamePath)')">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\GOG.com\Games\1453375253', 'PATH', null, RegistryView.Registry32))</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32))</GamePath>
+
+ <!-- derive from Steam library path -->
+ <_SteamLibraryPath>$([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Valve\Steam', 'SteamPath', null, RegistryView.Registry32))</_SteamLibraryPath>
+ <GamePath Condition="!Exists('$(GamePath)') AND '$(_SteamLibraryPath)' != ''">$(_SteamLibraryPath)\steamapps\common\Stardew Valley</GamePath>
</PropertyGroup>
</When>
</Choose>
@@ -62,40 +67,45 @@
<ItemGroup>
<Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
- <Reference Include="Netcode" Condition="Exists('$(GamePath)\Netcode.dll')">
+ <Reference Include="Netcode">
<HintPath>$(GamePath)\Netcode.dll</HintPath>
<Private>False</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Stardew Valley">
<HintPath>$(GamePath)\Stardew Valley.exe</HintPath>
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewModdingAPI">
<HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath>
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
+ </Reference>
+ <Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces">
+ <HintPath>$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath>
+ <Private>false</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>false</Private>
<SpecificVersion>False</SpecificVersion>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
</ItemGroup>
@@ -113,22 +123,27 @@
<HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
<Private>false</Private>
<SpecificVersion>False</SpecificVersion>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewValley">
<HintPath>$(GamePath)\StardewValley.exe</HintPath>
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewModdingAPI">
<HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath>
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
+ </Reference>
+ <Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces">
+ <HintPath>$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath>
+ <Private>false</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="xTile">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>false</Private>
- <Private Condition="$(ModUnitTests)">true</Private>
+ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
</ItemGroup>
</Otherwise>
@@ -160,6 +175,7 @@
ProjectDir="$(ProjectDir)"
TargetDir="$(TargetDir)"
GameDir="$(GamePath)"
+ IgnoreModFilePatterns="$(IgnoreModFilePatterns)"
/>
</Target>
</Project>
diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec
index 92e7e81e..3d6f2598 100644
--- a/src/SMAPI.ModBuildConfig/package.nuspec
+++ b/src/SMAPI.ModBuildConfig/package.nuspec
@@ -2,7 +2,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Pathoschild.Stardew.ModBuildConfig</id>
- <version>2.1-alpha20180410</version>
+ <version>2.1.0</version>
<title>Build package for SMAPI mods</title>
<authors>Pathoschild</authors>
<owners>Pathoschild</owners>
@@ -10,12 +10,15 @@
<licenseUrl>https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt</licenseUrl>
<projectUrl>https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme</projectUrl>
<iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl>
- <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods.</description>
+ <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For Stardew Valley 1.3 or later.</description>
<releaseNotes>
2.1:
- Added support for Stardew Valley 1.3.
- - Added support for unit test projects.
+ - Added support for non-mod projects.
- Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3.
+ - Added option to ignore files by regex pattern.
+ - Added reference to new SMAPI DLL.
+ - Fixed some game paths not detected by NuGet package.
</releaseNotes>
</metadata>
</package>
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs
index a6f42b98..37f4719e 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs
@@ -15,14 +15,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Provides methods for searching and constructing items.</summary>
private readonly ItemRepository Items = new ItemRepository();
+ /// <summary>The type names recognised by this command.</summary>
+ private readonly string[] ValidTypes = Enum.GetNames(typeof(ItemType)).Concat(new[] { "Name" }).ToArray();
+
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public AddCommand()
- : base("player_add", AddCommand.GetDescription())
- { }
+ : base("player_add", AddCommand.GetDescription()) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -30,35 +32,34 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
- // read arguments
- if (!args.TryGet(0, "item type", out string rawType, oneOf: Enum.GetNames(typeof(ItemType))))
+ // validate
+ if (!Context.IsWorldReady)
+ {
+ monitor.Log("You need to load a save to use this command.", LogLevel.Error);
return;
- if (!args.TryGetInt(1, "item ID", out int id, min: 0))
+ }
+
+ // read arguments
+ if (!args.TryGet(0, "item type", out string type, oneOf: this.ValidTypes))
return;
if (!args.TryGetInt(2, "count", out int count, min: 1, required: false))
count = 1;
if (!args.TryGetInt(3, "quality", out int quality, min: Object.lowQuality, max: Object.bestQuality, required: false))
quality = Object.lowQuality;
- ItemType type = (ItemType)Enum.Parse(typeof(ItemType), rawType, ignoreCase: true);
// find matching item
- SearchableItem match = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id);
+ SearchableItem match = Enum.TryParse(type, true, out ItemType itemType)
+ ? this.FindItemByID(monitor, args, itemType)
+ : this.FindItemByName(monitor, args);
if (match == null)
- {
- monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error);
return;
- }
// apply count
match.Item.Stack = count;
// apply quality
if (match.Item is Object obj)
-#if STARDEW_VALLEY_1_3
obj.Quality = quality;
-#else
- obj.quality = quality;
-#endif
else if (match.Item is Tool tool)
tool.UpgradeLevel = quality;
@@ -67,9 +68,60 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
monitor.Log($"OK, added {match.Name} ({match.Type} #{match.ID}) to your inventory.", LogLevel.Info);
}
+
/*********
** Private methods
*********/
+ /// <summary>Get a matching item by its ID.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ /// <param name="args">The command arguments.</param>
+ /// <param name="type">The item type.</param>
+ private SearchableItem FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type)
+ {
+ // read arguments
+ if (!args.TryGetInt(1, "item ID", out int id, min: 0))
+ return null;
+
+ // find matching item
+ SearchableItem item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id);
+ if (item == null)
+ monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error);
+ return item;
+ }
+
+ /// <summary>Get a matching item by its name.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ /// <param name="args">The command arguments.</param>
+ private SearchableItem FindItemByName(IMonitor monitor, ArgumentParser args)
+ {
+ // read arguments
+ if (!args.TryGet(1, "item name", out string name))
+ return null;
+
+ // find matching items
+ SearchableItem[] matches = this.Items.GetAll().Where(p => p.NameContains(name)).ToArray();
+ if (!matches.Any())
+ {
+ monitor.Log($"There's no item with name '{name}'. You can use the 'list_items [name]' command to search for items.", LogLevel.Error);
+ return null;
+ }
+
+ // handle single exact match
+ SearchableItem[] exactMatches = matches.Where(p => p.NameEquivalentTo(name)).ToArray();
+ if (exactMatches.Length == 1)
+ return exactMatches[0];
+
+ // handle ambiguous results
+ string options = this.GetTableString(
+ data: matches,
+ header: new[] { "type", "name", "command" },
+ getRow: item => new[] { item.Type.ToString(), item.DisplayName, $"player_add {item.Type} {item.ID}" }
+ );
+ monitor.Log($"There's no item with name '{name}'. Do you mean one of these?\n\n{options}", LogLevel.Info);
+ return null;
+ }
+
+ /// <summary>Get the command description.</summary>
private static string GetDescription()
{
string[] typeValues = Enum.GetNames(typeof(ItemType));
@@ -81,8 +133,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
+ "- count (optional): how many of the item to give.\n"
+ $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n"
+ "\n"
- + "This example adds the galaxy sword to your inventory:\n"
- + " player_add weapon 4";
+ + "Usage: player_add name \"<name>\" [count] [quality]\n"
+ + "- name: the item name to search (use the 'list_items' command to see a list). This will add the item immediately if it's an exact match, else show a table of matching items.\n"
+ + "- count (optional): how many of the item to give.\n"
+ + $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n"
+ + "\n"
+ + "These examples both add the galaxy sword to your inventory:\n"
+ + " player_add weapon 4\n"
+ + " player_add name \"Galaxy Sword\"";
}
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs
index aa4fd105..f0815ef6 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs
@@ -36,11 +36,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
switch (target)
{
case "hair":
-#if STARDEW_VALLEY_1_3
Game1.player.hairstyleColor.Value = color;
-#else
- Game1.player.hairstyleColor = color;
-#endif
monitor.Log("OK, your hair color is updated.", LogLevel.Info);
break;
@@ -50,11 +46,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
break;
case "pants":
-#if STARDEW_VALLEY_1_3
Game1.player.pantsColor.Value = color;
-#else
- Game1.player.pantsColor = color;
-#endif
monitor.Log("OK, your pants color is updated.", LogLevel.Info);
break;
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs
deleted file mode 100644
index 68891267..00000000
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Collections.Generic;
-using StardewValley;
-using SFarmer = StardewValley.Farmer;
-
-namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
-{
- /// <summary>A command which edits the player's current level for a skill.</summary>
- internal class SetLevelCommand : TrainerCommand
- {
- /*********
- ** Properties
- *********/
- /// <summary>The experience points needed to reach each level.</summary>
- /// <remarks>Derived from <see cref="SFarmer.checkForLevelGain"/>.</remarks>
- private readonly IDictionary<int, int> LevelExp = new Dictionary<int, int>
- {
- [0] = 0,
- [1] = 100,
- [2] = 380,
- [3] = 770,
- [4] = 1300,
- [5] = 2150,
- [6] = 3300,
- [7] = 4800,
- [8] = 6900,
- [9] = 10000,
- [10] = 15000
- };
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- public SetLevelCommand()
- : base("player_setlevel", "Sets the player's specified skill to the specified value.\n\nUsage: player_setlevel <skill> <value>\n- skill: the skill to set (one of 'luck', 'mining', 'combat', 'farming', 'fishing', or 'foraging').\n- value: the target level (a number from 1 to 10).") { }
-
- /// <summary>Handle the command.</summary>
- /// <param name="monitor">Writes messages to the console and log file.</param>
- /// <param name="command">The command name.</param>
- /// <param name="args">The command arguments.</param>
- public override void Handle(IMonitor monitor, string command, ArgumentParser args)
- {
- // validate
- if (!args.TryGet(0, "skill", out string skill, oneOf: new[] { "luck", "mining", "combat", "farming", "fishing", "foraging" }))
- return;
- if (!args.TryGetInt(1, "level", out int level, min: 0, max: 10))
- return;
-
- // handle
- switch (skill)
- {
- case "luck":
- Game1.player.LuckLevel = level;
- Game1.player.experiencePoints[SFarmer.luckSkill] = this.LevelExp[level];
- monitor.Log($"OK, your luck skill is now {Game1.player.LuckLevel}.", LogLevel.Info);
- break;
-
- case "mining":
- Game1.player.MiningLevel = level;
- Game1.player.experiencePoints[SFarmer.miningSkill] = this.LevelExp[level];
- monitor.Log($"OK, your mining skill is now {Game1.player.MiningLevel}.", LogLevel.Info);
- break;
-
- case "combat":
- Game1.player.CombatLevel = level;
- Game1.player.experiencePoints[SFarmer.combatSkill] = this.LevelExp[level];
- monitor.Log($"OK, your combat skill is now {Game1.player.CombatLevel}.", LogLevel.Info);
- break;
-
- case "farming":
- Game1.player.FarmingLevel = level;
- Game1.player.experiencePoints[SFarmer.farmingSkill] = this.LevelExp[level];
- monitor.Log($"OK, your farming skill is now {Game1.player.FarmingLevel}.", LogLevel.Info);
- break;
-
- case "fishing":
- Game1.player.FishingLevel = level;
- Game1.player.experiencePoints[SFarmer.fishingSkill] = this.LevelExp[level];
- monitor.Log($"OK, your fishing skill is now {Game1.player.FishingLevel}.", LogLevel.Info);
- break;
-
- case "foraging":
- Game1.player.ForagingLevel = level;
- Game1.player.experiencePoints[SFarmer.foragingSkill] = this.LevelExp[level];
- monitor.Log($"OK, your foraging skill is now {Game1.player.ForagingLevel}.", LogLevel.Info);
- break;
- }
- }
- }
-}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs
index 71e17f71..e8cb0584 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs
@@ -39,11 +39,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
case "farm":
if (!string.IsNullOrWhiteSpace(name))
{
-#if STARDEW_VALLEY_1_3
Game1.player.farmName.Value = args[1];
-#else
- Game1.player.farmName = args[1];
-#endif
monitor.Log($"OK, your farm's name is now {Game1.player.farmName}.", LogLevel.Info);
}
else
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs
deleted file mode 100644
index e9693540..00000000
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using StardewValley;
-
-namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
-{
- /// <summary>A command which edits the player's current added speed.</summary>
- internal class SetSpeedCommand : TrainerCommand
- {
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- public SetSpeedCommand()
- : base("player_setspeed", "Sets the player's added speed to the specified value.\n\nUsage: player_setspeed <value>\n- value: an integer amount (0 is normal).") { }
-
- /// <summary>Handle the command.</summary>
- /// <param name="monitor">Writes messages to the console and log file.</param>
- /// <param name="command">The command name.</param>
- /// <param name="args">The command arguments.</param>
- public override void Handle(IMonitor monitor, string command, ArgumentParser args)
- {
- // parse arguments
- if (!args.TryGetInt(0, "added speed", out int amount, min: 0))
- return;
-
- // handle
- Game1.player.addedSpeed = amount;
- monitor.Log($"OK, your added speed is now {Game1.player.addedSpeed}.", LogLevel.Info);
- }
- }
-}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs
index b59be2e5..31f4107d 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs
@@ -1,4 +1,4 @@
-using StardewValley;
+using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
@@ -10,7 +10,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
*********/
/// <summary>Construct an instance.</summary>
public SetStyleCommand()
- : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changecolor <target> <value>.\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the integer style ID.") { }
+ : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle <target> <value>.\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the integer style ID.") { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs
index c83c3b07..2cec0fd3 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs
@@ -21,11 +21,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int level = (Game1.currentLocation as MineShaft)?.mineLevel ?? 0;
monitor.Log($"OK, warping you to mine level {level + 1}.", LogLevel.Info);
-#if STARDEW_VALLEY_1_3
Game1.enterMine(level + 1);
-#else
- Game1.enterMine(false, level + 1, "");
-#endif
}
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs
index 5947af1a..b4f6d5b3 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs
@@ -26,11 +26,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
// handle
level = Math.Max(1, level);
monitor.Log($"OK, warping you to mine level {level}.", LogLevel.Info);
-#if STARDEW_VALLEY_1_3
Game1.enterMine(level);
-#else
- Game1.enterMine(true, level, "");
-#endif
}
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs
index 897d052f..b5db9c0d 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
@@ -38,7 +38,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
return;
// handle
- Game1.currentSeason = season;
+ Game1.currentSeason = season.ToLower();
+ Game1.setGraphicsForSeason();
monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info);
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs
index d6c71387..7644ee46 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs
@@ -1,4 +1,5 @@
-using System.Linq;
+using System;
+using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
@@ -31,9 +32,38 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
return;
// handle
- Game1.timeOfDay = time;
+ this.SafelySetTime(time);
FreezeTimeCommand.FrozenTime = Game1.timeOfDay;
monitor.Log($"OK, the time is now {Game1.timeOfDay.ToString().PadLeft(4, '0')}.", LogLevel.Info);
}
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Safely transition to the given time, allowing NPCs to update their schedule.</summary>
+ /// <param name="time">The time of day.</param>
+ private void SafelySetTime(int time)
+ {
+ // define conversion between game time and TimeSpan
+ TimeSpan ToTimeSpan(int value) => new TimeSpan(0, value / 100, value % 100, 0);
+ int FromTimeSpan(TimeSpan span) => (int)((span.Hours * 100) + span.Minutes);
+
+ // transition to new time
+ int intervals = (int)((ToTimeSpan(time) - ToTimeSpan(Game1.timeOfDay)).TotalMinutes / 10);
+ if (intervals > 0)
+ {
+ for (int i = 0; i < intervals; i++)
+ Game1.performTenMinuteClockUpdate();
+ }
+ else if (intervals < 0)
+ {
+ for (int i = 0; i > intervals; i--)
+ {
+ Game1.timeOfDay = FromTimeSpan(ToTimeSpan(Game1.timeOfDay).Subtract(TimeSpan.FromMinutes(20))); // offset 20 mins so game updates to next interval
+ Game1.performTenMinuteClockUpdate();
+ }
+ }
+ }
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs
index 3eede413..b618a308 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs
@@ -1,4 +1,5 @@
-using StardewValley;
+using System;
+using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData
{
@@ -37,5 +38,23 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData
this.ID = id;
this.Item = item;
}
+
+ /// <summary>Get whether the item name contains a case-insensitive substring.</summary>
+ /// <param name="substring">The substring to find.</param>
+ public bool NameContains(string substring)
+ {
+ return
+ this.Name.IndexOf(substring, StringComparison.InvariantCultureIgnoreCase) != -1
+ || this.DisplayName.IndexOf(substring, StringComparison.InvariantCultureIgnoreCase) != -1;
+ }
+
+ /// <summary>Get whether the item name is exactly equal to a case-insensitive string.</summary>
+ /// <param name="name">The substring to find.</param>
+ public bool NameEquivalentTo(string name)
+ {
+ return
+ this.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)
+ || this.DisplayName.Equals(name, StringComparison.InvariantCultureIgnoreCase);
+ }
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
index 9c0981c4..e678d057 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
@@ -83,9 +83,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
foreach (int id in Game1.bigCraftablesInformation.Keys)
yield return new SearchableItem(ItemType.BigCraftable, id, new SObject(Vector2.Zero, id));
+ // secret notes
+ foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
+ {
+ SObject note = new SObject(79, 1);
+ note.name = $"{note.name} #{id}";
+ yield return new SearchableItem(ItemType.Object, this.CustomIDOffset + id, note);
+ }
+
// objects
foreach (int id in Game1.objectInformation.Keys)
{
+ if (id == 79)
+ continue; // secret note handled above
if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
continue; // handled separated
@@ -96,28 +106,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
if (item.category == SObject.FruitsCategory)
{
// wine
-#if STARDEW_VALLEY_1_3
- SObject wine =
- new SObject(348, 1)
+ SObject wine = new SObject(348, 1)
{
Name = $"{item.Name} Wine",
Price = item.price * 3
};
wine.preserve.Value = SObject.PreserveType.Wine;
wine.preservedParentSheetIndex.Value = item.parentSheetIndex;
-#else
- SObject wine = new SObject(348, 1)
- {
- name = $"{item.Name} Wine",
- price = item.price * 3,
- preserve = SObject.PreserveType.Wine,
- preservedParentSheetIndex = item.parentSheetIndex
- };
-#endif
- yield return new SearchableItem(ItemType.Object, this.CustomIDOffset + id, wine);
+ yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, wine);
// jelly
-#if STARDEW_VALLEY_1_3
SObject jelly = new SObject(344, 1)
{
Name = $"{item.Name} Jelly",
@@ -125,23 +123,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
};
jelly.preserve.Value = SObject.PreserveType.Jelly;
jelly.preservedParentSheetIndex.Value = item.parentSheetIndex;
-#else
- SObject jelly = new SObject(344, 1)
- {
- name = $"{item.Name} Jelly",
- price = 50 + item.Price * 2,
- preserve = SObject.PreserveType.Jelly,
- preservedParentSheetIndex = item.parentSheetIndex
- };
-#endif
- yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, jelly);
+ yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, jelly);
}
// vegetable products
else if (item.category == SObject.VegetableCategory)
{
// juice
-#if STARDEW_VALLEY_1_3
SObject juice = new SObject(350, 1)
{
Name = $"{item.Name} Juice",
@@ -149,19 +137,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
};
juice.preserve.Value = SObject.PreserveType.Juice;
juice.preservedParentSheetIndex.Value = item.parentSheetIndex;
-#else
- SObject juice = new SObject(350, 1)
- {
- name = $"{item.Name} Juice",
- price = (int)(item.price * 2.25d),
- preserve = SObject.PreserveType.Juice,
- preservedParentSheetIndex = item.parentSheetIndex
- };
-#endif
- yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, juice);
+ yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, juice);
// pickled
-#if STARDEW_VALLEY_1_3
SObject pickled = new SObject(342, 1)
{
Name = $"Pickled {item.Name}",
@@ -169,16 +147,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
};
pickled.preserve.Value = SObject.PreserveType.Pickle;
pickled.preservedParentSheetIndex.Value = item.parentSheetIndex;
-#else
- SObject pickled = new SObject(342, 1)
- {
- name = $"Pickled {item.Name}",
- price = 50 + item.Price * 2,
- preserve = SObject.PreserveType.Pickle,
- preservedParentSheetIndex = item.parentSheetIndex
- };
-#endif
- yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, pickled);
+ yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, pickled);
}
// flower honey
@@ -211,7 +180,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// yield honey
if (type != null)
{
-#if STARDEW_VALLEY_1_3
SObject honey = new SObject(Vector2.Zero, 340, item.Name + " Honey", false, true, false, false)
{
Name = "Wild Honey"
@@ -223,18 +191,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
honey.Name = $"{item.Name} Honey";
honey.Price += item.Price * 2;
}
-#else
- SObject honey = new SObject(Vector2.Zero, 340, item.Name + " Honey", false, true, false, false)
- {
- name = "Wild Honey",
- honeyType = type
- };
- if (type != SObject.HoneyType.Wild)
- {
- honey.name = $"{item.Name} Honey";
- honey.price += item.price * 2;
- }
-#endif
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, honey);
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/ConsoleCommandsMod.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs
index 96658928..7588043d 100644
--- a/src/SMAPI.Mods.ConsoleCommands/ConsoleCommandsMod.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs
@@ -7,7 +7,7 @@ using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands;
namespace StardewModdingAPI.Mods.ConsoleCommands
{
/// <summary>The main entry point for the mod.</summary>
- public class ConsoleCommandsMod : Mod
+ public class ModEntry : Mod
{
/*********
** Properties
diff --git a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj
index d1f72c6c..50b7b87f 100644
--- a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj
+++ b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj
@@ -38,7 +38,7 @@
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
- <Private>True</Private>
+ <Private>False</Private>
</Reference>
<Reference Include="System" />
<Reference Include="System.Xml" />
@@ -56,8 +56,6 @@
<Compile Include="Framework\Commands\Player\AddCommand.cs" />
<Compile Include="Framework\Commands\Player\SetStyleCommand.cs" />
<Compile Include="Framework\Commands\Player\SetColorCommand.cs" />
- <Compile Include="Framework\Commands\Player\SetSpeedCommand.cs" />
- <Compile Include="Framework\Commands\Player\SetLevelCommand.cs" />
<Compile Include="Framework\Commands\Player\SetMaxHealthCommand.cs" />
<Compile Include="Framework\Commands\Player\SetMaxStaminaCommand.cs" />
<Compile Include="Framework\Commands\Player\SetHealthCommand.cs" />
@@ -77,7 +75,7 @@
<Compile Include="Framework\Commands\ITrainerCommand.cs" />
<Compile Include="Framework\ItemData\SearchableItem.cs" />
<Compile Include="Framework\ItemRepository.cs" />
- <Compile Include="ConsoleCommandsMod.cs" />
+ <Compile Include="ModEntry.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index b6f9b81c..f89049c6 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -1,7 +1,7 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
- "Version": "2.5.5",
+ "Version": "2.6.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll"
diff --git a/src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs b/src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs
new file mode 100644
index 00000000..c9dcb216
--- /dev/null
+++ b/src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs
@@ -0,0 +1,9 @@
+namespace StardewModdingAPI.Mods.SaveBackup.Framework
+{
+ /// <summary>The mod configuration.</summary>
+ internal class ModConfig
+ {
+ /// <summary>The number of backups to keep.</summary>
+ public int BackupsToKeep { get; set; } = 10;
+ }
+}
diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs
new file mode 100644
index 00000000..78578c3c
--- /dev/null
+++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Reflection;
+using StardewModdingAPI.Mods.SaveBackup.Framework;
+using StardewValley;
+
+namespace StardewModdingAPI.Mods.SaveBackup
+{
+ /// <summary>The main entry point for the mod.</summary>
+ public class ModEntry : Mod
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The name of the save archive to create.</summary>
+ private readonly string FileName = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}.zip";
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>The mod entry point, called after the mod is first loaded.</summary>
+ /// <param name="helper">Provides simplified APIs for writing mods.</param>
+ public override void Entry(IModHelper helper)
+ {
+ try
+ {
+ ModConfig config = this.Helper.ReadConfig<ModConfig>();
+
+ // init backup folder
+ DirectoryInfo backupFolder = new DirectoryInfo(Path.Combine(this.Helper.DirectoryPath, "backups"));
+ backupFolder.Create();
+
+ // back up saves
+ this.CreateBackup(backupFolder);
+ this.PruneBackups(backupFolder, config.BackupsToKeep);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Error backing up saves: {ex}");
+ }
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Back up the current saves.</summary>
+ /// <param name="backupFolder">The folder containing save backups.</param>
+ private void CreateBackup(DirectoryInfo backupFolder)
+ {
+ try
+ {
+ // get target path
+ FileInfo targetFile = new FileInfo(Path.Combine(backupFolder.FullName, this.FileName));
+ if (targetFile.Exists)
+ targetFile.Delete(); //return;
+
+ // create zip
+ // due to limitations with the bundled Mono on Mac, we can't reference System.IO.Compression.
+ this.Monitor.Log($"Adding {targetFile.Name}...", LogLevel.Trace);
+ switch (Constants.TargetPlatform)
+ {
+ case GamePlatform.Linux:
+ case GamePlatform.Windows:
+ {
+ Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly.");
+ Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly.");
+ Type compressionLevelType = coreAssembly.GetType("System.IO.Compression.CompressionLevel") ?? throw new InvalidOperationException("Can't load CompressionLevel type.");
+ Type zipFileType = fsAssembly.GetType("System.IO.Compression.ZipFile") ?? throw new InvalidOperationException("Can't load ZipFile type.");
+ MethodInfo createMethod = zipFileType.GetMethod("CreateFromDirectory", new[] { typeof(string), typeof(string), compressionLevelType, typeof(bool) }) ?? throw new InvalidOperationException("Can't load ZipFile.CreateFromDirectory method.");
+ createMethod.Invoke(null, new object[] { Constants.SavesPath, targetFile.FullName, CompressionLevel.Fastest, false });
+ }
+ break;
+
+ case GamePlatform.Mac:
+ {
+ DirectoryInfo saveFolder = new DirectoryInfo(Constants.SavesPath);
+ ProcessStartInfo startInfo = new ProcessStartInfo
+ {
+ FileName = "zip",
+ Arguments = $"-rq \"{targetFile.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"",
+ WorkingDirectory = $"{Constants.SavesPath}/../",
+ CreateNoWindow = true
+ };
+ new Process { StartInfo = startInfo }.Start();
+ }
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log("Couldn't back up save files (see log file for details).", LogLevel.Warn);
+ this.Monitor.Log(ex.ToString(), LogLevel.Trace);
+ }
+ }
+
+ /// <summary>Remove old backups if we've exceeded the limit.</summary>
+ /// <param name="backupFolder">The folder containing save backups.</param>
+ /// <param name="backupsToKeep">The number of backups to keep.</param>
+ private void PruneBackups(DirectoryInfo backupFolder, int backupsToKeep)
+ {
+ try
+ {
+ var oldBackups = backupFolder
+ .GetFiles()
+ .OrderByDescending(p => p.CreationTimeUtc)
+ .Skip(backupsToKeep);
+
+ foreach (FileInfo file in oldBackups)
+ {
+ try
+ {
+ this.Monitor.Log($"Deleting {file.Name}...", LogLevel.Trace);
+ file.Delete();
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Error deleting old save backup '{file.Name}': {ex}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log("Couldn't remove old backups (see log file for details).", LogLevel.Warn);
+ this.Monitor.Log(ex.ToString(), LogLevel.Trace);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs b/src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..fc6b26fa
--- /dev/null
+++ b/src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Reflection;
+
+[assembly: AssemblyTitle("StardewModdingAPI.Mods.SaveBackup")]
+[assembly: AssemblyDescription("")]
diff --git a/src/SMAPI.AssemblyRewriters/StardewModdingAPI.AssemblyRewriters.csproj b/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj
index 651b822d..0ccbcc6c 100644
--- a/src/SMAPI.AssemblyRewriters/StardewModdingAPI.AssemblyRewriters.csproj
+++ b/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj
@@ -1,30 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
-<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">x86</Platform>
- <ProjectGuid>{10DB0676-9FC1-4771-A2C8-E2519F091E49}</ProjectGuid>
+ <ProjectGuid>{E272EB5D-8C57-417E-8E60-C1079D3F53C4}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
- <RootNamespace>StardewModdingAPI.AssemblyRewriters</RootNamespace>
- <AssemblyName>StardewModdingAPI.AssemblyRewriters</AssemblyName>
+ <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace>
+ <AssemblyName>SaveBackup</AssemblyName>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
</PropertyGroup>
- <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
+ <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
- <OutputPath>bin\Debug\</OutputPath>
+ <OutputPath>$(SolutionDir)\..\bin\Debug\Mods\SaveBackup\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
- <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
+ <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
- <OutputPath>bin\Release\</OutputPath>
+ <OutputPath>$(SolutionDir)\..\bin\Release\Mods\SaveBackup\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
@@ -36,8 +36,26 @@
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
+ <Compile Include="Framework\ModConfig.cs" />
+ <Compile Include="ModEntry.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
- <Compile Include="SpriteBatchMethods.cs" />
+ </ItemGroup>
+ <ItemGroup>
+ <None Include="manifest.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\SMAPI\StardewModdingAPI.csproj">
+ <Project>{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}</Project>
+ <Name>StardewModdingAPI</Name>
+ <Private>False</Private>
+ </ProjectReference>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj">
+ <Project>{d5cfd923-37f1-4bc3-9be8-e506e202ac28}</Project>
+ <Name>StardewModdingAPI.Toolkit.CoreInterfaces</Name>
+ <Private>False</Private>
+ </ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
new file mode 100644
index 00000000..ee0f2abb
--- /dev/null
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -0,0 +1,8 @@
+{
+ "Name": "Save Backup",
+ "Author": "SMAPI",
+ "Version": "2.6.0",
+ "Description": "Automatically backs up all your saves once per day into its folder.",
+ "UniqueID": "SMAPI.SaveBackup",
+ "EntryDll": "SaveBackup.dll"
+}
diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs
index d63eb1a2..a38621f8 100644
--- a/src/SMAPI.Tests/Core/ModResolverTests.cs
+++ b/src/SMAPI.Tests/Core/ModResolverTests.cs
@@ -6,10 +6,10 @@ using Moq;
using Newtonsoft.Json;
using NUnit.Framework;
using StardewModdingAPI.Framework;
-using StardewModdingAPI.Framework.ModData;
-using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModLoading;
-using StardewModdingAPI.Framework.Serialisation;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
namespace StardewModdingAPI.Tests.Core
{
@@ -31,7 +31,7 @@ namespace StardewModdingAPI.Tests.Core
Directory.CreateDirectory(rootFolder);
// act
- IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray();
+ IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
// assert
Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead.");
@@ -46,7 +46,7 @@ namespace StardewModdingAPI.Tests.Core
Directory.CreateDirectory(modFolder);
// act
- IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray();
+ IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata mod = mods.FirstOrDefault();
// assert
@@ -85,7 +85,7 @@ namespace StardewModdingAPI.Tests.Core
File.WriteAllText(filename, JsonConvert.SerializeObject(original));
// act
- IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray();
+ IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata mod = mods.FirstOrDefault();
// assert
@@ -93,8 +93,8 @@ namespace StardewModdingAPI.Tests.Core
Assert.IsNotNull(mod, "The loaded manifest shouldn't be null.");
Assert.AreEqual(null, mod.DataRecord, "The data record should be null since we didn't provide one.");
Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match.");
- Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match.");
Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded.");
+ Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match.");
Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name.");
Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match.");
@@ -142,7 +142,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
- this.SetupMetadataForValidation(mock, new ParsedModDataRecord
+ this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields
{
Status = ModStatus.AssumeBroken,
AlternativeUrl = "http://example.org"
@@ -160,7 +160,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
- mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = new SemanticVersion("1.1")));
+ mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1"));
this.SetupMetadataForValidation(mock);
// act
@@ -174,7 +174,7 @@ namespace StardewModdingAPI.Tests.Core
public void ValidateManifests_MissingEntryDLL_Fails()
{
// arrange
- Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest("Mod A", "1.0", manifest => manifest.EntryDll = "Missing.dll"), allowStatusChange: true);
+ Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true);
this.SetupMetadataForValidation(mock);
// act
@@ -189,7 +189,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
Mock<IModMetadata> modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
- Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod A", "1.0", manifest => manifest.Name = "Mod B"), allowStatusChange: true);
+ Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true);
Mock<IModMetadata> modC = this.GetMetadata("Mod C", new string[0], allowStatusChange: false);
foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC })
this.SetupMetadataForValidation(mod);
@@ -398,8 +398,8 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A 1.0 ◀── B (need A 1.1)
- Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
- Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true);
+ Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0"));
+ Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.1") }), allowStatusChange: true);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray();
@@ -414,8 +414,8 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A 1.0 ◀── B (need A 1.0-beta)
- Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
- Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false);
+ Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0"));
+ Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0-beta") }), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray();
@@ -431,8 +431,8 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A ◀── B
- Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
- Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false);
+ Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0"));
+ Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object, modA.Object }, new ModDatabase()).ToArray();
@@ -448,7 +448,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A ◀── B where A doesn't exist
- Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false);
+ Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object }, new ModDatabase()).ToArray();
@@ -463,46 +463,27 @@ namespace StardewModdingAPI.Tests.Core
** Private methods
*********/
/// <summary>Get a randomised basic manifest.</summary>
- /// <param name="adjust">Adjust the generated manifest.</param>
- private Manifest GetManifest(Action<Manifest> adjust = null)
+ /// <param name="id">The <see cref="IManifest.UniqueID"/> value, or <c>null</c> for a generated value.</param>
+ /// <param name="name">The <see cref="IManifest.Name"/> value, or <c>null</c> for a generated value.</param>
+ /// <param name="version">The <see cref="IManifest.Version"/> value, or <c>null</c> for a generated value.</param>
+ /// <param name="entryDll">The <see cref="IManifest.EntryDll"/> value, or <c>null</c> for a generated value.</param>
+ /// <param name="contentPackForID">The <see cref="IManifest.ContentPackFor"/> value.</param>
+ /// <param name="minimumApiVersion">The <see cref="IManifest.MinimumApiVersion"/> value.</param>
+ /// <param name="dependencies">The <see cref="IManifest.Dependencies"/> value.</param>
+ private Manifest GetManifest(string id = null, string name = null, string version = null, string entryDll = null, string contentPackForID = null, string minimumApiVersion = null, IManifestDependency[] dependencies = null)
{
- Manifest manifest = new Manifest
+ return new Manifest
{
- Name = Sample.String(),
+ UniqueID = id ?? $"{Sample.String()}.{Sample.String()}",
+ Name = name ?? id ?? Sample.String(),
Author = Sample.String(),
- Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()),
Description = Sample.String(),
- UniqueID = $"{Sample.String()}.{Sample.String()}",
- EntryDll = $"{Sample.String()}.dll"
+ Version = version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()),
+ EntryDll = entryDll ?? $"{Sample.String()}.dll",
+ ContentPackFor = contentPackForID != null ? new ManifestContentPackFor { UniqueID = contentPackForID } : null,
+ MinimumApiVersion = minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null,
+ Dependencies = dependencies
};
- adjust?.Invoke(manifest);
- return manifest;
- }
-
- /// <summary>Get a randomised basic manifest.</summary>
- /// <param name="uniqueID">The mod's name and unique ID.</param>
- /// <param name="version">The mod version.</param>
- /// <param name="adjust">Adjust the generated manifest.</param>
- /// <param name="dependencies">The dependencies this mod requires.</param>
- private IManifest GetManifest(string uniqueID, string version, Action<Manifest> adjust, params IManifestDependency[] dependencies)
- {
- return this.GetManifest(manifest =>
- {
- manifest.Name = uniqueID;
- manifest.UniqueID = uniqueID;
- manifest.Version = new SemanticVersion(version);
- manifest.Dependencies = dependencies;
- adjust?.Invoke(manifest);
- });
- }
-
- /// <summary>Get a randomised basic manifest.</summary>
- /// <param name="uniqueID">The mod's name and unique ID.</param>
- /// <param name="version">The mod version.</param>
- /// <param name="dependencies">The dependencies this mod requires.</param>
- private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies)
- {
- return this.GetManifest(uniqueID, version, null, dependencies);
}
/// <summary>Get a randomised basic manifest.</summary>
@@ -518,7 +499,7 @@ namespace StardewModdingAPI.Tests.Core
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
private Mock<IModMetadata> GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false)
{
- IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray());
+ IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray());
return this.GetMetadata(manifest, allowStatusChange);
}
@@ -545,7 +526,7 @@ namespace StardewModdingAPI.Tests.Core
/// <summary>Set up a mock mod metadata for <see cref="ModResolver.ValidateManifests"/>.</summary>
/// <param name="mod">The mock mod metadata.</param>
/// <param name="modRecord">The extra metadata about the mod from SMAPI's internal data (if any).</param>
- private void SetupMetadataForValidation(Mock<IModMetadata> mod, ParsedModDataRecord modRecord = null)
+ private void SetupMetadataForValidation(Mock<IModMetadata> mod, ModDataRecordVersionedFields modRecord = null)
{
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DataRecord).Returns(() => null);
diff --git a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
index 0c793817..b2d98d23 100644
--- a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
+++ b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
@@ -32,10 +32,10 @@
</PropertyGroup>
<ItemGroup>
<Reference Include="Castle.Core, Version=4.0.0.0, Culture=neutral, PublicKeyToken=407dd0808d44fbdc, processorArchitecture=MSIL">
- <HintPath>..\packages\Castle.Core.4.2.1\lib\net45\Castle.Core.dll</HintPath>
+ <HintPath>..\packages\Castle.Core.4.3.1\lib\net45\Castle.Core.dll</HintPath>
</Reference>
<Reference Include="Moq, Version=4.8.0.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL">
- <HintPath>..\packages\Moq.4.8.2\lib\net45\Moq.dll</HintPath>
+ <HintPath>..\packages\Moq.4.8.3\lib\net45\Moq.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
@@ -45,18 +45,21 @@
</Reference>
<Reference Include="System" />
<Reference Include="System.Configuration" />
- <Reference Include="System.Threading.Tasks.Extensions, Version=4.1.1.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
- <HintPath>..\packages\System.Threading.Tasks.Extensions.4.4.0\lib\portable-net45+win8+wp8+wpa81\System.Threading.Tasks.Extensions.dll</HintPath>
+ <Reference Include="System.Runtime.CompilerServices.Unsafe, Version=4.0.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
+ <HintPath>..\packages\System.Runtime.CompilerServices.Unsafe.4.5.1\lib\netstandard1.0\System.Runtime.CompilerServices.Unsafe.dll</HintPath>
</Reference>
- <Reference Include="System.ValueTuple, Version=4.0.2.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
- <HintPath>..\packages\System.ValueTuple.4.4.0\lib\netstandard1.0\System.ValueTuple.dll</HintPath>
+ <Reference Include="System.Threading.Tasks.Extensions, Version=4.2.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+ <HintPath>..\packages\System.Threading.Tasks.Extensions.4.5.1\lib\portable-net45+win8+wp8+wpa81\System.Threading.Tasks.Extensions.dll</HintPath>
+ </Reference>
+ <Reference Include="System.ValueTuple, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+ <HintPath>..\packages\System.ValueTuple.4.5.0\lib\netstandard1.0\System.ValueTuple.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
- <Compile Include="Core\PathUtilitiesTests.cs" />
+ <Compile Include="Toolkit\PathUtilitiesTests.cs" />
<Compile Include="Utilities\SemanticVersionTests.cs" />
<Compile Include="Utilities\SDateTests.cs" />
<Compile Include="Core\TranslationTests.cs" />
@@ -73,6 +76,14 @@
<Project>{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}</Project>
<Name>StardewModdingAPI</Name>
</ProjectReference>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj">
+ <Project>{d5cfd923-37f1-4bc3-9be8-e506e202ac28}</Project>
+ <Name>StardewModdingAPI.Toolkit.CoreInterfaces</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj">
+ <Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project>
+ <Name>StardewModdingAPI.Toolkit</Name>
+ </ProjectReference>
</ItemGroup>
<ItemGroup>
<Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" />
diff --git a/src/SMAPI.Tests/Core/PathUtilitiesTests.cs b/src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs
index 268ba504..229b9a14 100644
--- a/src/SMAPI.Tests/Core/PathUtilitiesTests.cs
+++ b/src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs
@@ -1,7 +1,7 @@
using NUnit.Framework;
-using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Toolkit.Utilities;
-namespace StardewModdingAPI.Tests.Core
+namespace StardewModdingAPI.Tests.Toolkit
{
/// <summary>Unit tests for <see cref="PathUtilities"/>.</summary>
[TestFixture]
diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
index f1a72012..35d74b60 100644
--- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
+++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
@@ -22,6 +22,7 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")]
[TestCase("1.2-some-tag.4", ExpectedResult = "1.2-some-tag.4")]
[TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
+ [TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")]
[TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")]
public string Constructor_FromString(string input)
{
@@ -35,6 +36,7 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")]
[TestCase(1, 2, 3, "0", ExpectedResult = "1.2.3-0")]
[TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
+ [TestCase(1, 2, 3, "sOMe-TaG.4", ExpectedResult = "1.2.3-sOMe-TaG.4")]
[TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")]
public string Constructor_FromParts(int major, int minor, int patch, string tag)
{
@@ -49,6 +51,19 @@ namespace StardewModdingAPI.Tests.Utilities
return version.ToString();
}
+ [Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")]
+ [TestCase(0, 0, 0, null)]
+ [TestCase(-1, 0, 0, null)]
+ [TestCase(0, -1, 0, null)]
+ [TestCase(0, 0, -1, null)]
+ [TestCase(1, 0, 0, "-tag")]
+ [TestCase(1, 0, 0, "tag spaces")]
+ [TestCase(1, 0, 0, "tag~")]
+ public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string tag)
+ {
+ this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, tag));
+ }
+
[Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")]
[TestCase(1, 0, 0, ExpectedResult = "1.0")]
[TestCase(1, 2, 3, ExpectedResult = "1.2.3")]
@@ -79,6 +94,7 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase("1.2.3.apple")]
[TestCase("1..2..3")]
[TestCase("1.2.3-")]
+ [TestCase("1.2.3--some-tag")]
[TestCase("1.2.3-some-tag...")]
[TestCase("1.2.3-some-tag...4")]
[TestCase("apple")]
@@ -271,22 +287,6 @@ namespace StardewModdingAPI.Tests.Utilities
Assert.IsTrue(version.IsOlderThan(new SemanticVersion("1.2.30")), "The game version should be considered older than the later semantic versions.");
}
- /****
- ** LegacyManifestVersion
- ****/
- [Test(Description = "Assert that the LegacyManifestVersion subclass correctly parses legacy manifest versions.")]
- [TestCase(1, 0, 0, null, ExpectedResult = "1.0")]
- [TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")]
- [TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")]
- [TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")]
- [TestCase(1, 2, 3, "0", ExpectedResult = "1.2.3")] // special case: drop '0' tag for legacy manifest versions
- [TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
- [TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")]
- public string LegacyManifestVersion(int major, int minor, int patch, string tag)
- {
- return new LegacyManifestVersion(major, minor, patch, tag).ToString();
- }
-
/*********
** Private methods
diff --git a/src/SMAPI.Tests/app.config b/src/SMAPI.Tests/app.config
index 7d8c9227..673b91c4 100644
--- a/src/SMAPI.Tests/app.config
+++ b/src/SMAPI.Tests/app.config
@@ -4,7 +4,11 @@
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
- <bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
+ <bindingRedirect oldVersion="0.0.0.0-4.2.0.0" newVersion="4.2.0.0" />
+ </dependentAssembly>
+ <dependentAssembly>
+ <assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
+ <bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
diff --git a/src/SMAPI.Tests/packages.config b/src/SMAPI.Tests/packages.config
index bad26cfa..7c3ec9f1 100644
--- a/src/SMAPI.Tests/packages.config
+++ b/src/SMAPI.Tests/packages.config
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
- <package id="Castle.Core" version="4.2.1" targetFramework="net45" />
- <package id="Moq" version="4.8.2" targetFramework="net45" />
+ <package id="Castle.Core" version="4.3.1" targetFramework="net45" />
+ <package id="Moq" version="4.8.3" targetFramework="net45" />
<package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" />
<package id="NUnit" version="3.10.1" targetFramework="net45" />
- <package id="System.Threading.Tasks.Extensions" version="4.4.0" targetFramework="net45" />
- <package id="System.ValueTuple" version="4.4.0" targetFramework="net45" />
+ <package id="System.Runtime.CompilerServices.Unsafe" version="4.5.1" targetFramework="net45" />
+ <package id="System.Threading.Tasks.Extensions" version="4.5.1" targetFramework="net45" />
+ <package id="System.ValueTuple" version="4.5.0" targetFramework="net45" />
</packages> \ No newline at end of file
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs
index 0464e50a..8c4a0332 100644
--- a/src/SMAPI.Web/Controllers/IndexController.cs
+++ b/src/SMAPI.Web/Controllers/IndexController.cs
@@ -1,10 +1,15 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using HtmlAgilityPack;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
-using StardewModdingAPI.Common;
+using Microsoft.Extensions.Options;
+using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
+using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers
@@ -17,6 +22,9 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Properties
*********/
+ /// <summary>The site config settings.</summary>
+ private readonly SiteConfig SiteConfig;
+
/// <summary>The cache in which to store release data.</summary>
private readonly IMemoryCache Cache;
@@ -24,7 +32,7 @@ namespace StardewModdingAPI.Web.Controllers
private readonly IGitHubClient GitHub;
/// <summary>The cache time for release info.</summary>
- private readonly TimeSpan CacheTime = TimeSpan.FromSeconds(1);
+ private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10);
/// <summary>The GitHub repository name to check for update.</summary>
private readonly string RepositoryName = "Pathoschild/SMAPI";
@@ -36,34 +44,35 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store release data.</param>
/// <param name="github">The GitHub API client.</param>
- public IndexController(IMemoryCache cache, IGitHubClient github)
+ /// <param name="siteConfig">The context config settings.</param>
+ public IndexController(IMemoryCache cache, IGitHubClient github, IOptions<SiteConfig> siteConfig)
{
this.Cache = cache;
this.GitHub = github;
+ this.SiteConfig = siteConfig.Value;
}
/// <summary>Display the index page.</summary>
[HttpGet]
public async Task<ViewResult> Index()
{
- // fetch SMAPI releases
- IndexVersionModel stableVersion = await this.Cache.GetOrCreateAsync("stable-version", async entry =>
- {
- entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
- GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false);
- return new IndexVersionModel(release.Name, release.Body, this.GetMainDownloadUrl(release), this.GetDevDownloadUrl(release));
- });
- IndexVersionModel betaVersion = await this.Cache.GetOrCreateAsync("beta-version", async entry =>
- {
- entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
- GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true);
- return release.IsPrerelease
- ? this.GetBetaDownload(release)
- : null;
- });
+ // choose versions
+ ReleaseVersion[] versions = await this.GetReleaseVersionsAsync();
+ ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsBeta && !version.IsForDevs);
+ ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => !version.IsBeta && version.IsForDevs);
+ ReleaseVersion betaVersion = versions.LastOrDefault(version => version.IsBeta && !version.IsForDevs);
+ ReleaseVersion betaVersionForDevs = versions.LastOrDefault(version => version.IsBeta && version.IsForDevs);
// render view
- var model = new IndexModel(stableVersion, betaVersion);
+ IndexVersionModel stableVersionModel = stableVersion != null
+ ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl)
+ : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong)
+ IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.EnableSmapiBeta
+ ? new IndexVersionModel(betaVersion.Version.ToString(), betaVersion.Release.Body, betaVersion.Asset.DownloadUrl, betaVersionForDevs?.Asset.DownloadUrl)
+ : null;
+
+ // render view
+ var model = new IndexModel(stableVersionModel, betaVersionModel);
return this.View(model);
}
@@ -71,62 +80,109 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Private methods
*********/
- /// <summary>Get the main download URL for a SMAPI release.</summary>
- /// <param name="release">The SMAPI release.</param>
- private string GetMainDownloadUrl(GitRelease release)
+ /// <summary>Get a sorted, parsed list of SMAPI downloads for the latest releases.</summary>
+ private async Task<ReleaseVersion[]> GetReleaseVersionsAsync()
{
- // get main download URL
- foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
+ return await this.Cache.GetOrCreateAsync("available-versions", async entry =>
{
- if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip"))
- return asset.DownloadUrl;
- }
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
- // fallback just in case
- return "https://github.com/pathoschild/SMAPI/releases";
- }
+ // get latest release (whether preview or stable)
+ GitRelease stableRelease = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true);
- /// <summary>Get the for-developers download URL for a SMAPI release.</summary>
- /// <param name="release">The SMAPI release.</param>
- private string GetDevDownloadUrl(GitRelease release)
- {
- // get dev download URL
- foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
- {
- if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip"))
- return asset.DownloadUrl;
- }
+ // split stable/prerelease if applicable
+ GitRelease betaRelease = null;
+ if (stableRelease.IsPrerelease)
+ {
+ GitRelease result = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false);
+ if (result != null)
+ {
+ betaRelease = stableRelease;
+ stableRelease = result;
+ }
+ }
+
+ // strip 'noinclude' blocks from release descriptions
+ foreach (GitRelease release in new[] { stableRelease, betaRelease })
+ {
+ if (release == null)
+ continue;
+
+ HtmlDocument doc = new HtmlDocument();
+ doc.LoadHtml(release.Body);
+ foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0])
+ node.Remove();
+ release.Body = doc.DocumentNode.InnerHtml.Trim();
+ }
- // fallback just in case
- return "https://github.com/pathoschild/SMAPI/releases";
+ // get versions
+ ReleaseVersion[] stableVersions = this.ParseReleaseVersions(stableRelease).ToArray();
+ ReleaseVersion[] betaVersions = this.ParseReleaseVersions(betaRelease).ToArray();
+ return stableVersions
+ .Concat(betaVersions)
+ .OrderBy(p => p.Version)
+ .ToArray();
+ });
}
- /// <summary>Get the latest beta download for a SMAPI release.</summary>
- /// <param name="release">The SMAPI release.</param>
- private IndexVersionModel GetBetaDownload(GitRelease release)
+ /// <summary>Get a parsed list of SMAPI downloads for a release.</summary>
+ /// <param name="release">The GitHub release.</param>
+ private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease release)
{
- // get download with the latest version
- SemanticVersionImpl latestVersion = null;
- string latestUrl = null;
- foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
+ if (release?.Assets == null)
+ yield break;
+
+ foreach (GitAsset asset in release.Assets)
{
- // parse version
- Match versionMatch = Regex.Match(asset.FileName, @"SMAPI-([\d\.]+(?:-.+)?)-installer.zip");
- if (!versionMatch.Success || !SemanticVersionImpl.TryParse(versionMatch.Groups[1].Value, out SemanticVersionImpl version))
+ Match match = Regex.Match(asset.FileName, @"SMAPI-(?<version>[\d\.]+(?:-.+)?)-installer(?<forDevs>-for-developers)?.zip");
+ if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version))
continue;
+ bool isBeta = version.IsPrerelease();
+ bool isForDevs = match.Groups["forDevs"].Success;
- // save latest version
- if (latestVersion == null || latestVersion.CompareTo(version) < 0)
- {
- latestVersion = version;
- latestUrl = asset.DownloadUrl;
- }
+ yield return new ReleaseVersion(release, asset, version, isBeta, isForDevs);
}
+ }
- // return if prerelease
- return latestVersion?.Tag != null
- ? new IndexVersionModel(latestVersion.ToString(), release.Body, latestUrl, null)
- : null;
+ /// <summary>A parsed release download.</summary>
+ private class ReleaseVersion
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The underlying GitHub release.</summary>
+ public GitRelease Release { get; }
+
+ /// <summary>The underlying download asset.</summary>
+ public GitAsset Asset { get; }
+
+ /// <summary>The SMAPI version.</summary>
+ public ISemanticVersion Version { get; }
+
+ /// <summary>Whether this is a beta download.</summary>
+ public bool IsBeta { get; }
+
+ /// <summary>Whether this is a 'for developers' download.</summary>
+ public bool IsForDevs { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="release">The underlying GitHub release.</param>
+ /// <param name="asset">The underlying download asset.</param>
+ /// <param name="version">The SMAPI version.</param>
+ /// <param name="isBeta">Whether this is a beta download.</param>
+ /// <param name="isForDevs">Whether this is a 'for developers' download.</param>
+ public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isBeta, bool isForDevs)
+ {
+ this.Release = release;
+ this.Asset = asset;
+ this.Version = version;
+ this.IsBeta = isBeta;
+ this.IsForDevs = isForDevs;
+ }
}
}
}
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index 62547deb..17f8d3aa 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.IO.Compression;
+using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
@@ -20,8 +21,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Properties
*********/
- /// <summary>The log parser config settings.</summary>
- private readonly ContextConfig Config;
+ /// <summary>The site config settings.</summary>
+ private readonly SiteConfig Config;
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
@@ -38,11 +39,11 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor
***/
/// <summary>Construct an instance.</summary>
- /// <param name="contextProvider">The context config settings.</param>
+ /// <param name="siteConfig">The context config settings.</param>
/// <param name="pastebin">The Pastebin API client.</param>
- public LogParserController(IOptions<ContextConfig> contextProvider, IPastebinClient pastebin)
+ public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin)
{
- this.Config = contextProvider.Value;
+ this.Config = siteConfig.Value;
this.Pastebin = pastebin;
}
@@ -51,34 +52,49 @@ namespace StardewModdingAPI.Web.Controllers
***/
/// <summary>Render the log parser UI.</summary>
/// <param name="id">The paste ID.</param>
+ /// <param name="raw">Whether to display the raw unparsed log.</param>
[HttpGet]
[Route("log")]
[Route("log/{id}")]
- public async Task<ViewResult> Index(string id = null)
+ public async Task<ViewResult> Index(string id = null, bool raw = false)
{
// fresh page
if (string.IsNullOrWhiteSpace(id))
- return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, null));
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id));
// log page
PasteInfo paste = await this.GetAsync(id);
ParsedLog log = paste.Success
? new LogParser().Parse(paste.Content)
: new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error };
- return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log));
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw));
}
/***
** JSON
***/
/// <summary>Save raw log data.</summary>
- /// <param name="content">The log content to save.</param>
- [HttpPost, Produces("application/json"), AllowLargePosts]
- [Route("log/save")]
- public async Task<SavePasteResult> PostAsync([FromBody] string content)
+ [HttpPost, AllowLargePosts]
+ [Route("log")]
+ public async Task<ActionResult> PostAsync()
{
- content = this.CompressString(content);
- return await this.Pastebin.PostAsync(content);
+ // get raw log text
+ string input = this.Request.Form["input"].FirstOrDefault();
+ if (string.IsNullOrWhiteSpace(input))
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." });
+
+ // upload log
+ input = this.CompressString(input);
+ SavePasteResult result = await this.Pastebin.PostAsync(input);
+
+ // handle errors
+ if (!result.Success)
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" });
+
+ // redirect to view
+ UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl));
+ uri.Path = uri.Path.TrimEnd('/') + '/' + result.ID;
+ return this.Redirect(uri.Uri.ToString());
}
@@ -115,7 +131,7 @@ namespace StardewModdingAPI.Web.Controllers
}
// prefix length
- var zipBuffer = new byte[compressedData.Length + 4];
+ byte[] zipBuffer = new byte[compressedData.Length + 4];
Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
@@ -151,7 +167,7 @@ namespace StardewModdingAPI.Web.Controllers
memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
// read data
- var buffer = new byte[dataLength];
+ byte[] buffer = new byte[dataLength];
memoryStream.Position = 0;
using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
gZipStream.Read(buffer, 0, buffer.Length);
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 24517263..b500e19d 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -1,12 +1,17 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
+using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
+using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
@@ -38,19 +43,28 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>A regex which matches SMAPI-style semantic version.</summary>
private readonly string VersionRegex;
+ /// <summary>The internal mod metadata list.</summary>
+ private readonly ModDatabase ModDatabase;
+
+ /// <summary>The web URL for the wiki compatibility list.</summary>
+ private readonly string WikiCompatibilityPageUrl;
+
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
+ /// <param name="environment">The web hosting environment.</param>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
/// <param name="chucklefish">The Chucklefish API client.</param>
/// <param name="github">The GitHub API client.</param>
/// <param name="nexus">The Nexus API client.</param>
- public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
+ public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
{
+ this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json"));
ModUpdateCheckConfig config = configProvider.Value;
+ this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl;
this.Cache = cache;
this.SuccessCacheMinutes = config.SuccessCacheMinutes;
@@ -67,76 +81,126 @@ namespace StardewModdingAPI.Web.Controllers
}
/// <summary>Fetch version metadata for the given mods.</summary>
- /// <param name="modKeys">The namespaced mod keys to search as a comma-delimited array.</param>
- /// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param>
- [HttpGet]
- public async Task<IDictionary<string, ModInfoModel>> GetAsync(string modKeys, bool allowInvalidVersions = false)
+ /// <param name="model">The mod search criteria.</param>
+ [HttpPost]
+ public async Task<object> PostAsync([FromBody] ModSearchModel model)
{
- string[] modKeysArray = modKeys?.Split(',').ToArray();
- if (modKeysArray == null || !modKeysArray.Any())
- return new Dictionary<string, ModInfoModel>();
+ // parse request data
+ ISemanticVersion apiVersion = this.GetApiVersion();
+ ModSearchEntryModel[] searchMods = this.GetSearchMods(model, apiVersion).ToArray();
+
+ // fetch wiki data
+ WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync();
+
+ // fetch data
+ IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
+ foreach (ModSearchEntryModel mod in searchMods)
+ {
+ if (string.IsNullOrWhiteSpace(mod.ID))
+ continue;
+
+ ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata);
+ result.SetBackwardsCompatibility(apiVersion);
+ mods[mod.ID] = result;
+ }
- return await this.PostAsync(new ModSearchModel(modKeysArray, allowInvalidVersions));
+ // return in expected structure
+ return apiVersion.IsNewerThan("2.6-beta.18")
+ ? mods.Values
+ : (object)mods;
}
- /// <summary>Fetch version metadata for the given mods.</summary>
- /// <param name="search">The mod search criteria.</param>
- [HttpPost]
- public async Task<IDictionary<string, ModInfoModel>> PostAsync([FromBody] ModSearchModel search)
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the metadata for a mod.</summary>
+ /// <param name="search">The mod data to match.</param>
+ /// <param name="wikiData">The wiki data.</param>
+ /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
+ /// <returns>Returns the mod data if found, else <c>null</c>.</returns>
+ private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiCompatibilityEntry[] wikiData, bool includeExtendedMetadata)
{
- // parse model
- bool allowInvalidVersions = search?.AllowInvalidVersions ?? false;
- string[] modKeys = (search?.ModKeys?.ToArray() ?? new string[0])
- .Distinct(StringComparer.CurrentCultureIgnoreCase)
- .OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase)
- .ToArray();
+ // resolve update keys
+ var updateKeys = new HashSet<string>(search.UpdateKeys ?? new string[0], StringComparer.InvariantCultureIgnoreCase);
+ ModDataRecord record = this.ModDatabase.Get(search.ID);
+ if (record?.Fields != null)
+ {
+ string defaultUpdateKey = record.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value;
+ if (!string.IsNullOrWhiteSpace(defaultUpdateKey))
+ updateKeys.Add(defaultUpdateKey);
+ }
- // fetch mod info
- IDictionary<string, ModInfoModel> result = new Dictionary<string, ModInfoModel>(StringComparer.CurrentCultureIgnoreCase);
- foreach (string modKey in modKeys)
+ // get latest versions
+ ModEntryModel result = new ModEntryModel { ID = search.ID };
+ IList<string> errors = new List<string>();
+ foreach (string updateKey in updateKeys)
{
- // parse mod key
- if (!this.TryParseModKey(modKey, out string vendorKey, out string modID))
+ // fetch data
+ ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey);
+ if (data.Error != null)
{
- result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
+ errors.Add(data.Error);
continue;
}
- // get matching repository
- if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
+ // handle main version
+ if (data.Version != null)
{
- result[modKey] = new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
- continue;
+ if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version))
+ {
+ errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'.");
+ continue;
+ }
+
+ if (this.IsNewer(version, result.Main?.Version))
+ result.Main = new ModEntryVersionModel(version, data.Url);
}
- // fetch mod info
- result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
+ // handle optional version
+ if (data.PreviewVersion != null)
{
- // fetch info
- ModInfoModel info = await repository.GetModInfoAsync(modID);
-
- // validate
- if (info.Error == null)
+ if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version))
{
- if (info.Version == null)
- info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number.");
- if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
- info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'.");
+ errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'.");
+ continue;
}
- // cache & return
- entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
- return info;
- });
+ if (this.IsNewer(version, result.Optional?.Version))
+ result.Optional = new ModEntryVersionModel(version, data.Url);
+ }
}
+ // get unofficial version
+ WikiCompatibilityEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(result.ID.Trim(), StringComparer.InvariantCultureIgnoreCase));
+ if (wikiEntry?.UnofficialVersion != null && this.IsNewer(wikiEntry.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.UnofficialVersion, result.Optional?.Version))
+ result.Unofficial = new ModEntryVersionModel(wikiEntry.UnofficialVersion, this.WikiCompatibilityPageUrl);
+
+ // fallback to preview if latest is invalid
+ if (result.Main == null && result.Optional != null)
+ {
+ result.Main = result.Optional;
+ result.Optional = null;
+ }
+
+ // special cases
+ if (result.ID == "Pathoschild.SMAPI")
+ {
+ if (result.Main != null)
+ result.Main.Url = "https://smapi.io/";
+ if (result.Optional != null)
+ result.Optional.Url = "https://smapi.io/";
+ }
+
+ // add extended metadata
+ if (includeExtendedMetadata && (wikiEntry != null || record != null))
+ result.Metadata = new ModExtendedMetadataModel(wikiEntry, record);
+
+ // add result
+ result.Errors = errors.ToArray();
return result;
}
-
- /*********
- ** Private methods
- *********/
/// <summary>Parse a namespaced mod ID.</summary>
/// <param name="raw">The raw mod ID to parse.</param>
/// <param name="vendorKey">The parsed vendor key.</param>
@@ -158,5 +222,91 @@ namespace StardewModdingAPI.Web.Controllers
modID = parts[1].Trim();
return true;
}
+
+ /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary>
+ /// <param name="current">The current version.</param>
+ /// <param name="other">The other version.</param>
+ private bool IsNewer(ISemanticVersion current, ISemanticVersion other)
+ {
+ return current != null && (other == null || other.IsOlderThan(current));
+ }
+
+ /// <summary>Get the mods for which the API should return data.</summary>
+ /// <param name="model">The search model.</param>
+ /// <param name="apiVersion">The requested API version.</param>
+ private IEnumerable<ModSearchEntryModel> GetSearchMods(ModSearchModel model, ISemanticVersion apiVersion)
+ {
+ if (model == null)
+ yield break;
+
+ // yield standard entries
+ if (model.Mods != null)
+ {
+ foreach (ModSearchEntryModel mod in model.Mods)
+ yield return mod;
+ }
+
+ // yield mod update keys if backwards compatible
+ if (model.ModKeys != null && model.ModKeys.Any() && !apiVersion.IsNewerThan("2.6-beta.17"))
+ {
+ foreach (string updateKey in model.ModKeys.Distinct())
+ yield return new ModSearchEntryModel(updateKey, new[] { updateKey });
+ }
+ }
+
+ /// <summary>Get mod data from the wiki compatibility list.</summary>
+ private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync()
+ {
+ ModToolkit toolkit = new ModToolkit();
+ return await this.Cache.GetOrCreateAsync($"_wiki", async entry =>
+ {
+ try
+ {
+ WikiCompatibilityEntry[] entries = await toolkit.GetWikiCompatibilityListAsync();
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes);
+ return entries;
+ }
+ catch
+ {
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes);
+ return new WikiCompatibilityEntry[0];
+ }
+ });
+ }
+
+ /// <summary>Get the mod info for an update key.</summary>
+ /// <param name="updateKey">The namespaced update key.</param>
+ private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey)
+ {
+ // parse update key
+ if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID))
+ return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
+
+ // get matching repository
+ if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
+ return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
+
+ // fetch mod info
+ return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
+ {
+ ModInfoModel result = await repository.GetModInfoAsync(modID);
+ if (result.Error != null)
+ {
+ if (result.Version == null)
+ result.Error = $"The update key '{updateKey}' matches a mod with no version number.";
+ else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
+ result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'.";
+ }
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
+ return result;
+ });
+ }
+
+ /// <summary>Get the requested API version.</summary>
+ private ISemanticVersion GetApiVersion()
+ {
+ string actualVersion = (string)this.RouteData.Values["version"];
+ return new SemanticVersion(actualVersion);
+ }
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
deleted file mode 100644
index adec41be..00000000
--- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using System.Threading.Tasks;
-using Pathoschild.Http.Client;
-
-namespace StardewModdingAPI.Web.Framework.Clients.Nexus
-{
- /// <summary>An HTTP client for fetching mod metadata from the Nexus Mods API.</summary>
- internal class NexusClient : INexusClient
- {
- /*********
- ** Properties
- *********/
- /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary>
- private readonly string ModUrlFormat;
-
- /// <summary>The underlying HTTP client.</summary>
- private readonly IClient Client;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="userAgent">The user agent for the Nexus Mods API client.</param>
- /// <param name="baseUrl">The base URL for the Nexus Mods API.</param>
- /// <param name="modUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
- public NexusClient(string userAgent, string baseUrl, string modUrlFormat)
- {
- this.ModUrlFormat = modUrlFormat;
- this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
- }
-
- /// <summary>Get metadata about a mod.</summary>
- /// <param name="id">The Nexus mod ID.</param>
- /// <returns>Returns the mod info if found, else <c>null</c>.</returns>
- public async Task<NexusMod> GetModAsync(uint id)
- {
- return await this.Client
- .GetAsync(string.Format(this.ModUrlFormat, id))
- .As<NexusMod>();
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- public void Dispose()
- {
- this.Client?.Dispose();
- }
- }
-}
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs
index cd52c72b..4ecf2f76 100644
--- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
@@ -14,6 +15,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/// <summary>The mod's semantic version number.</summary>
public string Version { get; set; }
+ /// <summary>The latest file version.</summary>
+ public ISemanticVersion LatestFileVersion { get; set; }
+
/// <summary>The mod's web URL.</summary>
[JsonProperty("mod_page_uri")]
public string Url { get; set; }
diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs
index d0597965..1b3fa195 100644
--- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs
@@ -1,8 +1,11 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Net;
using System.Threading.Tasks;
using HtmlAgilityPack;
using Pathoschild.Http.Client;
+using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
@@ -12,9 +15,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/*********
** Properties
*********/
- /// <summary>The URL for a Nexus web page excluding the base URL, where {0} is the mod ID.</summary>
+ /// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary>
private readonly string ModUrlFormat;
+ /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary>
+ public string ModScrapeUrlFormat { get; set; }
+
/// <summary>The underlying HTTP client.</summary>
private readonly IClient Client;
@@ -25,10 +31,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/// <summary>Construct an instance.</summary>
/// <param name="userAgent">The user agent for the Nexus Mods API client.</param>
/// <param name="baseUrl">The base URL for the Nexus Mods site.</param>
- /// <param name="modUrlFormat">The URL for a Nexus Mods web page excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
- public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat)
+ /// <param name="modUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
+ /// <param name="modScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param>
+ public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat)
{
this.ModUrlFormat = modUrlFormat;
+ this.ModScrapeUrlFormat = modScrapeUrlFormat;
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
}
@@ -42,7 +50,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
try
{
html = await this.Client
- .GetAsync(string.Format(this.ModUrlFormat, id))
+ .GetAsync(string.Format(this.ModScrapeUrlFormat, id))
.AsString();
}
catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
@@ -75,11 +83,43 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
string url = this.GetModUrl(id);
string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim();
string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim();
+ SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion);
+
+ // extract file versions
+ List<string> rawVersions = new List<string>();
+ foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]"))
+ {
+ string sectionName = fileSection.Descendants("h2").First().InnerText;
+ if (sectionName != "Main files" && sectionName != "Optional files")
+ continue;
+
+ rawVersions.AddRange(
+ from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version"))
+ from versionStat in statBox.Descendants().Where(p => p.HasClass("stat"))
+ select versionStat.InnerText.Trim()
+ );
+ }
+
+ // choose latest file version
+ ISemanticVersion latestFileVersion = null;
+ foreach (string rawVersion in rawVersions)
+ {
+ if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur))
+ continue;
+ if (parsedVersion != null && !cur.IsNewerThan(parsedVersion))
+ continue;
+ if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion))
+ continue;
+
+ latestFileVersion = cur;
+ }
+ // yield info
return new NexusMod
{
Name = name,
- Version = version,
+ Version = parsedVersion?.ToString() ?? version,
+ LatestFileVersion = latestFileVersion,
Url = url
};
}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
index de6c024a..ae8f18d2 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
@@ -47,24 +47,21 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/****
** Nexus Mods
****/
- /// <summary>The user agent for the Nexus Mods API client.</summary>
- public string NexusUserAgent { get; set; }
-
/// <summary>The base URL for the Nexus Mods API.</summary>
public string NexusBaseUrl { get; set; }
- /// <summary>The URL for a Nexus Mods API query excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
+ /// <summary>The URL for a Nexus mod page for the user, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
public string NexusModUrlFormat { get; set; }
+ /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
+ public string NexusModScrapeUrlFormat { get; set; }
+
/****
** Pastebin
****/
/// <summary>The base URL for the Pastebin API.</summary>
public string PastebinBaseUrl { get; set; }
- /// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary>
- public string PastebinUserAgent { get; set; }
-
/// <summary>The user key used to authenticate with the Pastebin API.</summary>
public string PastebinUserKey { get; set; }
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
index fc3b7dc2..ce4f3cb5 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
@@ -24,5 +24,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The repository key for Nexus Mods.</summary>
public string NexusKey { get; set; }
+
+ /// <summary>The web URL for the wiki compatibility list.</summary>
+ public string WikiCompatibilityPageUrl { get; set; }
}
}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
index 117462f4..3d428015 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
@@ -1,7 +1,7 @@
namespace StardewModdingAPI.Web.Framework.ConfigModels
{
- /// <summary>The config settings for the app context.</summary>
- public class ContextConfig // must be public to pass into views
+ /// <summary>The site config settings.</summary>
+ public class SiteConfig // must be public to pass into views
{
/*********
** Accessors
@@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The root URL for the log parser.</summary>
public string LogParserUrl { get; set; }
+
+ /// <summary>Whether to show SMAPI beta versions on the main page, if any.</summary>
+ public bool EnableSmapiBeta { get; set; }
}
}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index f49fb05c..013c6c47 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
-using StardewModdingAPI.Common;
+using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Web.Framework.LogParsing.Models;
namespace StardewModdingAPI.Web.Framework.LogParsing
@@ -31,13 +31,13 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary>
/// <remarks>The author name and description are optional.</remarks>
- private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersionImpl.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary>
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary>
- private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?) \| (?<description>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/*********
@@ -135,7 +135,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
{
Match match = this.ModPathPattern.Match(message.Text);
log.ModPath = match.Groups["path"].Value;
- log.GamePath = new FileInfo(log.ModPath).Directory.FullName;
+ int lastDelimiterPos = log.ModPath.LastIndexOfAny(new char[] { '/', '\\' });
+ log.GamePath = lastDelimiterPos >= 0
+ ? log.ModPath.Substring(0, lastDelimiterPos)
+ : log.ModPath;
}
// log UTC timestamp line
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
index 40d21bf8..759f15db 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
@@ -1,24 +1,29 @@
+using StardewModdingAPI.Internal.ConsoleWriting;
+
namespace StardewModdingAPI.Web.Framework.LogParsing.Models
{
/// <summary>The log severity levels.</summary>
public enum LogLevel
{
/// <summary>Tracing info intended for developers.</summary>
- Trace,
+ Trace = ConsoleLogLevel.Trace,
/// <summary>Troubleshooting info that may be relevant to the player.</summary>
- Debug,
+ Debug = ConsoleLogLevel.Debug,
/// <summary>Info relevant to the player. This should be used judiciously.</summary>
- Info,
+ Info = ConsoleLogLevel.Info,
/// <summary>An issue the player should be aware of. This should be used rarely.</summary>
- Warn,
+ Warn = ConsoleLogLevel.Warn,
/// <summary>A message indicating something went wrong.</summary>
- Error,
+ Error = ConsoleLogLevel.Error,
/// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary>
- Alert
+ Alert = ConsoleLogLevel.Alert,
+
+ /// <summary>A critical issue that generally signals an immediate end to the application.</summary>
+ Critical = ConsoleLogLevel.Critical
}
}
diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs
index edb00454..4a4a40cd 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs
@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{
diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
index 3e5a4272..e6074a60 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs
@@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
namespace StardewModdingAPI.Web.Framework.ModRepositories
diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
index 59eb8cd1..1d7e4fff 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs
@@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
namespace StardewModdingAPI.Web.Framework.ModRepositories
@@ -38,21 +38,25 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
// fetch info
try
{
- // get latest release
+ // get latest release (whether preview or stable)
GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true);
- GitRelease preview = null;
if (latest == null)
return new ModInfoModel("Found no mod with this ID.");
- // get latest stable release (if not latest)
+ // split stable/prerelease if applicable
+ GitRelease preview = null;
if (latest.IsPrerelease)
{
- preview = latest;
- latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false);
+ GitRelease result = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false);
+ if (result != null)
+ {
+ preview = latest;
+ latest = result;
+ }
}
// return data
- return new ModInfoModel(name: id, version: this.NormaliseVersion(latest?.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases");
+ return new ModInfoModel(name: id, version: this.NormaliseVersion(latest.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases");
}
catch (Exception ex)
{
diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs
index 4496400c..09c59a86 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs
@@ -1,6 +1,5 @@
using System;
using System.Threading.Tasks;
-using StardewModdingAPI.Common.Models;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{
diff --git a/src/SMAPI.Common/Models/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs
index 48df235a..18252298 100644
--- a/src/SMAPI.Common/Models/ModInfoModel.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Common.Models
+namespace StardewModdingAPI.Web.Framework.ModRepositories
{
/// <summary>Generic metadata about a mod.</summary>
internal class ModInfoModel
@@ -9,10 +9,10 @@ namespace StardewModdingAPI.Common.Models
/// <summary>The mod name.</summary>
public string Name { get; set; }
- /// <summary>The semantic version for the mod's latest release.</summary>
+ /// <summary>The mod's latest release number.</summary>
public string Version { get; set; }
- /// <summary>The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</summary>
+ /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary>
public string PreviewVersion { get; set; }
/// <summary>The mod's web URL.</summary>
@@ -43,7 +43,7 @@ namespace StardewModdingAPI.Common.Models
this.Version = version;
this.PreviewVersion = previewVersion;
this.Url = url;
- this.Error = error; // mainly initialised here for the JSON deserialiser
+ this.Error = error;
}
/// <summary>Construct an instance.</summary>
diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
index 6411ad4c..4afcda10 100644
--- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
+++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs
@@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
namespace StardewModdingAPI.Web.Framework.ModRepositories
@@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
return new ModInfoModel("Found no mod with this ID.");
if (mod.Error != null)
return new ModInfoModel(mod.Error);
- return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url);
+ return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url);
}
catch (Exception ex)
{
diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs
index cffb1092..2d6ec603 100644
--- a/src/SMAPI.Web/Framework/VersionConstraint.cs
+++ b/src/SMAPI.Web/Framework/VersionConstraint.cs
@@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Routing.Constraints;
-using StardewModdingAPI.Common;
+using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework
{
@@ -11,6 +11,6 @@ namespace StardewModdingAPI.Web.Framework
*********/
/// <summary>Construct an instance.</summary>
public VersionConstraint()
- : base(SemanticVersionImpl.Regex) { }
+ : base(SemanticVersion.Regex) { }
}
}
diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj
index e2eee8a8..6761c7ad 100644
--- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj
+++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj
@@ -10,18 +10,26 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="HtmlAgilityPack" Version="1.7.2" />
- <PackageReference Include="Markdig" Version="0.14.9" />
- <PackageReference Include="Microsoft.AspNetCore" Version="2.0.2" />
- <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.3" />
- <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.2" />
- <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.0.2" />
- <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.0.1" />
- <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.1.0" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.8.4" />
+ <PackageReference Include="Markdig" Version="0.15.0" />
+ <PackageReference Include="Microsoft.AspNetCore" Version="2.1.1" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.1" />
+ <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" />
+ <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" />
+ <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" />
+ <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" />
</ItemGroup>
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" />
</ItemGroup>
- <Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
+ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
+ <ItemGroup>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <Content Update="wwwroot\StardewModdingAPI.metadata.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </Content>
+ </ItemGroup>
</Project>
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 6c7ccecd..bf3ec9a1 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit.Serialisation;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
@@ -49,13 +50,16 @@ namespace StardewModdingAPI.Web
// init configuration
services
.Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck"))
- .Configure<ContextConfig>(this.Configuration.GetSection("Context"))
+ .Configure<SiteConfig>(this.Configuration.GetSection("Site"))
.Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint)))
.AddMemoryCache()
.AddMvc()
.ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider()))
.AddJsonOptions(options =>
{
+ foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters)
+ options.SerializerSettings.Converters.Add(converter);
+
options.SerializerSettings.Formatting = Formatting.Indented;
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
});
@@ -82,15 +86,11 @@ namespace StardewModdingAPI.Web
password: api.GitHubPassword
));
- //services.AddSingleton<INexusClient>(new NexusClient(
- // userAgent: api.NexusUserAgent,
- // baseUrl: api.NexusBaseUrl,
- // modUrlFormat: api.NexusModUrlFormat
- //));
services.AddSingleton<INexusClient>(new NexusWebScrapeClient(
userAgent: userAgent,
baseUrl: api.NexusBaseUrl,
- modUrlFormat: api.NexusModUrlFormat
+ modUrlFormat: api.NexusModUrlFormat,
+ modScrapeUrlFormat: api.NexusModScrapeUrlFormat
));
services.AddSingleton<IPastebinClient>(new PastebinClient(
@@ -153,23 +153,23 @@ namespace StardewModdingAPI.Web
));
// shortcut redirects
+ redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1"));
redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://stardewvalleywiki.com/Modding:SMAPI_compatibility"));
redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index"));
- redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1"));
+ redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI"));
// redirect legacy canimod.com URLs
var wikiRedirects = new Dictionary<string, string[]>
{
- ["Modding:Creating_a_SMAPI_mod"] = new[] { "^/for-devs/creating-a-smapi-mod", "^/guides/creating-a-smapi-mod" },
+ ["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" },
+ ["Modding:Modder_Guide"] = new[] { "^/for-devs/creating-a-smapi-mod", "^/guides/creating-a-smapi-mod", "^/for-devs/creating-a-smapi-mod-advanced-config" },
+ ["Modding:Player_Guide"] = new[] { "^/for-players/install-smapi", "^/guides/using-mods", "^/for-players/faqs", "^/for-players/intro", "^/for-players/use-mods", "^/guides/asking-for-help", "^/guides/smapi-faq" },
+
["Modding:Editing_XNB_files"] = new[] { "^/for-devs/creating-an-xnb-mod", "^/guides/creating-an-xnb-mod" },
["Modding:Event_data"] = new[] { "^/for-devs/events", "^/guides/events" },
["Modding:Gift_taste_data"] = new[] { "^/for-devs/npc-gift-tastes", "^/guides/npc-gift-tastes" },
["Modding:IDE_reference"] = new[] { "^/for-devs/creating-a-smapi-mod-ide-primer" },
- ["Modding:Installing_SMAPI"] = new[] { "^/for-players/install-smapi", "^/guides/using-mods" },
["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" },
- ["Modding:Player_FAQs"] = new[] { "^/for-players/faqs", "^/for-players/intro", "^/for-players/use-mods", "^/guides/asking-for-help", "^/guides/smapi-faq" },
- ["Modding:SMAPI_APIs"] = new[] { "^/for-devs/creating-a-smapi-mod-advanced-config" },
- ["Modding:Updating_deprecated_SMAPI_code"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" },
["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" }
};
foreach (KeyValuePair<string, string[]> pair in wikiRedirects)
diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs
index 8c026536..df36ca73 100644
--- a/src/SMAPI.Web/ViewModels/LogParserModel.cs
+++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs
@@ -1,3 +1,6 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
using StardewModdingAPI.Web.Framework.LogParsing.Models;
namespace StardewModdingAPI.Web.ViewModels
@@ -6,6 +9,13 @@ namespace StardewModdingAPI.Web.ViewModels
public class LogParserModel
{
/*********
+ ** Properties
+ *********/
+ /// <summary>A regex pattern matching characters to remove from a mod name to create the slug ID.</summary>
+ private readonly Regex SlugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+
+ /*********
** Accessors
*********/
/// <summary>The root URL for the log parser controller.</summary>
@@ -17,6 +27,15 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>The parsed log info.</summary>
public ParsedLog ParsedLog { get; set; }
+ /// <summary>Whether to show the raw unparsed log.</summary>
+ public bool ShowRaw { get; set; }
+
+ /// <summary>An error which occurred while uploading the log to Pastebin.</summary>
+ public string UploadError { get; set; }
+
+ /// <summary>An error which occurred while parsing the log file.</summary>
+ public string ParseError => this.ParsedLog?.Error;
+
/*********
** Public methods
@@ -27,12 +46,46 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>Construct an instance.</summary>
/// <param name="sectionUrl">The root URL for the log parser controller.</param>
/// <param name="pasteID">The paste ID.</param>
- /// <param name="parsedLog">The parsed log info.</param>
- public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog)
+ public LogParserModel(string sectionUrl, string pasteID)
{
this.SectionUrl = sectionUrl;
this.PasteID = pasteID;
+ this.ParsedLog = null;
+ this.ShowRaw = false;
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="sectionUrl">The root URL for the log parser controller.</param>
+ /// <param name="pasteID">The paste ID.</param>
+ /// <param name="parsedLog">The parsed log info.</param>
+ /// <param name="showRaw">Whether to show the raw unparsed log.</param>
+ public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog, bool showRaw)
+ : this(sectionUrl, pasteID)
+ {
this.ParsedLog = parsedLog;
+ this.ShowRaw = showRaw;
+ }
+
+ /// <summary>Get all content packs in the log grouped by the mod they're for.</summary>
+ public IDictionary<string, LogModInfo[]> GetContentPacksByMod()
+ {
+ // get all mods & content packs
+ LogModInfo[] mods = this.ParsedLog?.Mods;
+ if (mods == null || !mods.Any())
+ return new Dictionary<string, LogModInfo[]>();
+
+ // group by mod
+ return mods
+ .Where(mod => mod.ContentPackFor != null)
+ .GroupBy(mod => mod.ContentPackFor)
+ .ToDictionary(group => group.Key, group => group.ToArray());
+ }
+
+ /// <summary>Get a sanitised mod name that's safe to use in anchors, attributes, and URLs.</summary>
+ /// <param name="modName">The mod name.</param>
+ public string GetSlug(string modName)
+ {
+ return this.SlugInvalidCharPattern.Replace(modName, "");
}
}
}
diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml
index 4efb9f8a..361d01de 100644
--- a/src/SMAPI.Web/Views/Index/Index.cshtml
+++ b/src/SMAPI.Web/Views/Index/Index.cshtml
@@ -3,7 +3,9 @@
}
@model StardewModdingAPI.Web.ViewModels.IndexModel
@section Head {
- <link rel="stylesheet" href="~/Content/css/index.css" />
+ <link rel="stylesheet" href="~/Content/css/index.css?r=20180615" />
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script>
+ <script src="~/Content/js/index.js?r=20180615"></script>
}
<p id="blurb">
@@ -13,17 +15,29 @@
</p>
<div id="call-to-action">
- <a href="@Model.StableVersion.DownloadUrl" class="main-cta">Download SMAPI @Model.StableVersion.Version</a><br />
+ <div class="cta-dropdown">
+ <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br/>
+ <div class="dropdown-content">
+ <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a>
+ <a href="@Model.StableVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a>
+ </div>
+ </div><br />
+
@if (Model.BetaVersion != null)
{
- <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta">Download SMAPI @Model.BetaVersion.Version<br /><small>for Stardew Valley 1.3 beta</small></a><br />
+ <div class="cta-dropdown secondary-cta-dropdown">
+ <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta download">Download SMAPI @Model.BetaVersion.Version<br/><small>for Stardew Valley 1.3 beta</small></a><br/>
+ <div class="dropdown-content">
+ <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a>
+ <a href="@Model.BetaVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a>
+ </div>
+ </div><br />
}
- <a href="https://stardewvalleywiki.com/Modding:Installing_SMAPI" class="secondary-cta">Install guide</a><br />
- <a href="https://stardewvalleywiki.com/Modding:Player_FAQs" class="secondary-cta">FAQs</a><br />
- <img src="favicon.ico" />
+ <a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a><br />
+ <img id="pufferchick" src="Content/images/pufferchick.png" />
</div>
-<h2>Get help</h2>
+<h2 id="help">Get help</h2>
<ul>
<li><a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">Mod compatibility list</a></li>
<li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li>
@@ -31,7 +45,7 @@
@if (Model.BetaVersion == null)
{
- <h2>What's new in SMAPI @Model.StableVersion.Version?</h2>
+ <h2 id="whatsnew">What's new in SMAPI @Model.StableVersion.Version?</h2>
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
</div>
@@ -39,7 +53,7 @@
}
else
{
- <h2>What's new in...</h2>
+ <h2 id="whatsnew">What's new in...</h2>
<h3>SMAPI @Model.StableVersion.Version?</h3>
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
@@ -53,7 +67,7 @@ else
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p>
}
-<h2>Donate to support SMAPI ♥</h2>
+<h2 id="donate">Donate to support SMAPI ♥</h2>
<p>
SMAPI is an open-source project by Pathoschild. It will always be free, but donations
are much appreciated to help pay for development, server hosting, domain fees, coffee, etc.
@@ -75,15 +89,23 @@ else
Special thanks to
acerbicon,
<a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>,
+ cheesysteak,
+ hawkfalcon,
jwdred,
- OfficialPiAddict,
+ KNakamura,
+ Kono Tyran,
+ Pucklynn,
Robby LaFarge,
and a few anonymous users for their ongoing support; you're awesome! 🏅
</p>
-<h2>For mod creators</h2>
+<h2 id="modcreators">For mod creators</h2>
<ul>
<li><a href="@Model.StableVersion.DevDownloadUrl">SMAPI @Model.StableVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li>
+ @if (Model.BetaVersion != null)
+ {
+ <li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li>
+ }
<li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li>
<li>Need help? Come <a href="https://stardewvalleywiki.com/Modding:Community#Discord">chat on Discord</a>.</li>
</ul>
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 2d1c1b44..e735e8f3 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -1,76 +1,120 @@
+@using Newtonsoft.Json
+@using StardewModdingAPI.Web.Framework.LogParsing.Models
+@model StardewModdingAPI.Web.ViewModels.LogParserModel
+
@{
ViewData["Title"] = "SMAPI log parser";
+ IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod();
+ IDictionary<string, bool> defaultFilters = Enum
+ .GetValues(typeof(LogLevel))
+ .Cast<LogLevel>()
+ .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
+ JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
+}
- IDictionary<string, LogModInfo[]> contentPacks = Model.ParsedLog?.Mods
- ?.GroupBy(mod => mod.ContentPackFor)
- .Where(group => group.Key != null)
- .ToDictionary(group => group.Key, group => group.ToArray());
-
- Regex slugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
- string GetSlug(string modName)
+@section Head {
+ @if (Model.PasteID != null)
{
- return slugInvalidCharPattern.Replace(modName, "");
+ <meta name="robots" content="noindex" />
}
-}
-@using System.Text.RegularExpressions
-@using Newtonsoft.Json
-@using StardewModdingAPI.Web.Framework.LogParsing.Models
-@model StardewModdingAPI.Web.ViewModels.LogParserModel
-@section Head {
- <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180225" />
+ <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180627" />
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script>
- <script src="~/Content/js/log-parser.js?r=20180225"></script>
+ <script src="~/Content/js/log-parser.js?r=20180627"></script>
<script>
$(function() {
smapi.logParser({
logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)),
showPopup: @Json.Serialize(Model.ParsedLog == null),
- showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), new JsonSerializerSettings { Formatting = Formatting.None }),
- showLevels: {
- trace: false,
- debug: false,
- info: true,
- alert: true,
- warn: true,
- error: true
- }
+ showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting),
+ showLevels: @Json.Serialize(defaultFilters, noFormatting),
+ enableFilters: @Json.Serialize(!Model.ShowRaw)
}, '@Model.SectionUrl');
});
</script>
}
-@*********
-** Intro
-*********@
-<p id="blurb">This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.</p>
-
-@if (Model.ParsedLog?.IsValid == true)
+@* upload result banner *@
+@if (Model.UploadError != null)
{
- <div class="banner success" v-pre>
- <strong>The log was uploaded successfully!</strong><br/>
- Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br/>
- (Or <a id="upload-button" href="#">upload a new log</a>.)
+ <div class="banner error" v-pre>
+ <strong>Oops, the server ran into trouble saving that file.</strong><br />
+ <small v-pre>Error details: @Model.UploadError</small>
</div>
}
-else if (Model.ParsedLog?.IsValid == false)
+else if (Model.ParseError != null)
{
<div class="banner error" v-pre>
- <strong>Oops, couldn't parse that file. (Make sure you upload the log file, not the console text.)</strong><br />
+ <strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br />
Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
- (Or <a id="upload-button" href="#">upload a new log</a>.)<br />
+ (Or <a href="@Model.SectionUrl">upload a new log</a>.)<br />
<br />
- <small v-pre>Error details: @Model.ParsedLog.Error</small>
+ <small v-pre>Error details: @Model.ParseError</small>
</div>
}
-else
+else if (Model.ParsedLog?.IsValid == true)
{
- <input type="button" id="upload-button" value="Share a new log" />
+ <div class="banner success" v-pre>
+ <strong>Share this link to let someone else see the log:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
+ (Or <a href="@Model.SectionUrl">upload a new log</a>.)
+ </div>
}
-@*********
-** Parsed log
-*********@
+@* upload new log *@
+@if (Model.ParsedLog == null)
+{
+ <h2>Where do I find my SMAPI log?</h2>
+ <div>What system do you use?</div>
+ <ul id="os-list">
+ <li><input type="radio" name="os" value="linux" id="os-linux" /> <label for="os-linux">Linux</label></li>
+ <li><input type="radio" name="os" value="mac" id="os-mac" /> <label for="os-mac">Mac</label></li>
+ <li><input type="radio" name="os" value="windows" id="os-windows" /> <label for="os-windows">Windows</label></li>
+ </ul>
+ <div data-os="linux">
+ On Linux:
+ <ol>
+ <li>Open the Files app.</li>
+ <li>Click the options menu (might be labeled <em>Go</em> or <code>⋮</code>).</li>
+ <li>Choose <em>Enter Location</em>.</li>
+ <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li>
+ <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li>
+ </ol>
+ </div>
+ <div data-os="mac">
+ On Mac:
+ <ol>
+ <li>Open the Finder app.</li>
+ <li>Click <em>Go</em> at the top, then <em>Enter Location</em>.</li>
+ <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li>
+ <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li>
+ </ol>
+ </div>
+ <div data-os="windows">
+ On Windows:
+ <ol>
+ <li>Press the <code>Windows</code> and <code>R</code> buttons at the same time.</li>
+ <li>In the 'run' box that appears, enter this exact text: <pre>%appdata%\StardewValley\ErrorLogs</pre></li>
+ <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li>
+ </ol>
+ </div>
+
+ <h2>How do I share my log?</h2>
+ <form action="@Model.SectionUrl" method="post">
+ <ol>
+ <li>
+ Drag the file onto this textbox (or paste the text in):<br />
+ <textarea id="input" name="input" placeholder="paste log here"></textarea>
+ </li>
+ <li>
+ Click this button:<br />
+ <input type="submit" id="submit" value="save log" />
+ </li>
+ <li>On the new page, copy the URL and send it to the person helping you.</li>
+ </ol>
+ </form>
+}
+
+@* parsed log *@
@if (Model.ParsedLog?.IsValid == true)
{
<h2>Log info</h2>
@@ -95,17 +139,20 @@ else
</tr>
</table>
<br />
- <table id="mods">
+ <table id="mods" class="@(Model.ShowRaw ? "filters-disabled" : null)">
<caption>
Installed mods:
- <span class="notice txt"><i>click any mod to filter</i></span>
- <span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span>
- <span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span>
+ @if (!Model.ShowRaw)
+ {
+ <span class="notice txt"><i>click any mod to filter</i></span>
+ <span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span>
+ <span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span>
+ }
</caption>
@foreach (var mod in Model.ParsedLog.Mods.Where(p => p.ContentPackFor == null))
{
- <tr v-on:click="toggleMod('@GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@GetSlug(mod.Name)'] }">
- <td><input type="checkbox" v-bind:checked="showMods['@GetSlug(mod.Name)']" v-show="anyModsHidden" /></td>
+ <tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }">
+ <td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-show="anyModsHidden" /></td>
<td v-pre>
<strong>@mod.Name</strong> @mod.Version
@if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList))
@@ -134,36 +181,47 @@ else
</tr>
}
</table>
- <div id="filters">
- Filter messages:
- <span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> |
- <span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> |
- <span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> |
- <span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> |
- <span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> |
- <span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span>
- </div>
- <table id="log">
- @foreach (var message in Model.ParsedLog.Messages)
- {
- string levelStr = message.Level.ToString().ToLower();
+ @if (!Model.ShowRaw)
+ {
+ <div id="filters">
+ Filter messages:
+ <span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> |
+ <span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> |
+ <span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> |
+ <span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> |
+ <span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> |
+ <span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span>
+ </div>
- <tr class="@levelStr mod" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')">
- <td v-pre>@message.Time</td>
- <td v-pre>@message.Level.ToString().ToUpper()</td>
- <td v-pre data-title="@message.Mod">@message.Mod</td>
- <td v-pre>@message.Text</td>
- </tr>
- if (message.Repeated > 0)
+ <table id="log">
+ @foreach (var message in Model.ParsedLog.Messages)
{
- <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')">
- <td colspan="3"></td>
- <td v-pre><i>repeats [@message.Repeated] times.</i></td>
+ string levelStr = message.Level.ToString().ToLower();
+
+ <tr class="@levelStr mod" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr')">
+ <td v-pre>@message.Time</td>
+ <td v-pre>@message.Level.ToString().ToUpper()</td>
+ <td v-pre data-title="@message.Mod">@message.Mod</td>
+ <td v-pre>@message.Text</td>
</tr>
+ if (message.Repeated > 0)
+ {
+ <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr')">
+ <td colspan="3"></td>
+ <td v-pre><i>repeats [@message.Repeated] times.</i></td>
+ </tr>
+ }
}
- }
- </table>
+ </table>
+
+ <small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))?raw=true">view raw log</a></small>
+ }
+ else
+ {
+ <pre v-pre>@Model.ParsedLog.RawText</pre>
+ <small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))">view parsed log</a></small>
+ }
</div>
}
else if (Model.ParsedLog?.IsValid == false)
@@ -171,22 +229,3 @@ else if (Model.ParsedLog?.IsValid == false)
<h3>Raw log</h3>
<pre v-pre>@Model.ParsedLog.RawText</pre>
}
-
-<div id="upload-area">
- <div id="popup-upload" class="popup">
- <h1>Upload log file</h1>
- <div class="frame">
- <ol>
- <li><a href="https://stardewvalleywiki.com/Modding:Player_FAQs#SMAPI_log" target="_blank">Find your SMAPI log file</a> (not the console text).</li>
- <li>Drag the file onto the textbox below (or paste the text in).</li>
- <li>Click <em>Parse</em>.</li>
- </ol>
- <textarea id="input" placeholder="Paste or drag the log here"></textarea>
- <div class="buttons">
- <input type="button" id="submit" value="Parse" />
- <input type="button" id="cancel" value="Cancel" />
- </div>
- </div>
- </div>
- <div id="uploader"></div>
-</div>
diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
index ac98c71b..29da9100 100644
--- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml
+++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml
@@ -1,11 +1,12 @@
@using Microsoft.Extensions.Options
@using StardewModdingAPI.Web.Framework.ConfigModels
-@inject IOptions<ContextConfig> ContextConfig
+@inject IOptions<SiteConfig> SiteConfig
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ViewData["Title"] - SMAPI.io</title>
<link rel="stylesheet" href="~/Content/css/main.css" />
@RenderSection("Head", required: false)
@@ -14,8 +15,8 @@
<div id="sidebar">
<h4>SMAPI</h4>
<ul>
- <li><a href="@ContextConfig.Value.RootUrl">About SMAPI</a></li>
- <li><a href="@ContextConfig.Value.LogParserUrl">Log parser</a></li>
+ <li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li>
+ <li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li>
<li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li>
</ul>
</div>
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json
index 495af120..67bb7748 100644
--- a/src/SMAPI.Web/appsettings.Development.json
+++ b/src/SMAPI.Web/appsettings.Development.json
@@ -16,10 +16,13 @@
"Microsoft": "Information"
}
},
- "Context": {
+
+ "Site": {
"RootUrl": "http://localhost:59482/",
- "LogParserUrl": "http://localhost:59482/log/"
+ "LogParserUrl": "http://localhost:59482/log/",
+ "EnableSmapiBeta": false
},
+
"ApiClients": {
"GitHubUsername": null,
"GitHubPassword": null,
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index 095707a8..9e3270ae 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -13,10 +13,13 @@
"Default": "Warning"
}
},
- "Context": {
- "RootUrl": null, // see top note
- "LogParserUrl": null // see top note
+
+ "Site": {
+ "RootUrl": null, // see top note
+ "LogParserUrl": null, // see top note
+ "EnableSmapiBeta": null // see top note
},
+
"ApiClients": {
"UserAgent": "SMAPI/{0} (+https://smapi.io)",
@@ -30,9 +33,9 @@
"GitHubUsername": null, // see top note
"GitHubPassword": null, // see top note
- "NexusUserAgent": "Nexus Client v0.63.15",
- "NexusBaseUrl": "http://www.nexusmods.com/stardewvalley",
+ "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/",
"NexusModUrlFormat": "mods/{0}",
+ "NexusModScrapeUrlFormat": "mods/{0}?tab=files",
"PastebinBaseUrl": "https://pastebin.com/",
"PastebinUserKey": null, // see top note
@@ -46,6 +49,8 @@
"ChucklefishKey": "Chucklefish",
"GitHubKey": "GitHub",
- "NexusKey": "Nexus"
+ "NexusKey": "Nexus",
+
+ "WikiCompatibilityPageUrl": "https://smapi.io/compat"
}
}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css
index 06cd6fb4..514e1a5c 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/index.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/index.css
@@ -18,7 +18,8 @@ h1 {
text-align: center;
}
-#call-to-action a {
+#call-to-action a.main-cta,
+#call-to-action a.secondary-cta {
box-shadow: #caefab 0 1px 0 0 inset;
background: linear-gradient(#77d42a 5%, #5cb811 100%) #77d42a;
border-radius: 6px;
@@ -40,6 +41,58 @@ h1 {
text-shadow: #2b665e 0 1px 0;
}
+.cta-dropdown {
+ position: relative;
+ display: inline-block;
+ margin-bottom: 1em;
+}
+
+.cta-dropdown a.download {
+ margin-bottom: 0 !important;
+}
+
+.cta-dropdown .dropdown-content {
+ display: none;
+ position: absolute;
+ text-align: left;
+ box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
+ border: 1px solid #566963;
+ background: #5cb811;
+ border-top: 0;
+ border-radius: 0 0 6px 6px;
+ margin-top: -6px;
+ z-index: 1;
+}
+
+.cta-dropdown .dropdown-content a:hover {
+ background-color: #ddd;
+}
+
+.cta-dropdown .dropdown-content img {
+ width: 0.85em;
+ height: 0.85em;
+}
+
+.cta-dropdown.secondary-cta-dropdown .dropdown-content a:hover {
+ background-color: #566963;
+}
+
+.cta-dropdown.secondary-cta-dropdown .dropdown-content {
+ background-color: #768d87;
+ border-color: #566963;
+}
+
+.cta-dropdown.secondary-cta-dropdown .dropdown-content a {
+ color: #fff;
+ text-shadow: #2b665e 0 1px 0;
+}
+
+.cta-dropdown .dropdown-content a {
+ padding: 0.75em 1em;
+ text-decoration: none;
+ display: block;
+}
+
/*********
** Subsections
*********/
@@ -48,10 +101,6 @@ h1 {
padding-left: 1em;
}
-.github-description .noinclude {
- display: none;
-}
-
#support-links li small {
display: block;
width: 50em;
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
index 789274e2..1fcd1bff 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -1,12 +1,8 @@
/*********
** Main layout
*********/
-input[type="button"] {
- font-size: 20px;
- border-radius: 5px;
- outline: none;
- box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
- cursor: pointer;
+#content {
+ max-width: 100%;
}
caption {
@@ -20,15 +16,6 @@ caption {
font-family: monospace;
}
-input#upload-button {
- background: #ccf;
- border: 1px solid #000088;
-}
-
-input#upload-button {
- background: #eef;
-}
-
table caption {
font-weight: bold;
}
@@ -39,6 +26,7 @@ table caption {
.banner {
border: 2px solid gray;
border-radius: 5px;
+ margin-top: 1em;
padding: 1em;
}
@@ -91,6 +79,10 @@ table#metadata, table#mods {
cursor: pointer;
}
+#mods.filters-disabled tr {
+ cursor: default;
+}
+
#metadata tr,
#mods tr {
background: #eee
@@ -200,6 +192,12 @@ table#metadata, table#mods {
color: #f00;
}
+#log .critical {
+ background-color: #c00;
+ color: #fff;
+ font-weight: bold;
+}
+
#log {
border-spacing: 0;
}
@@ -256,88 +254,19 @@ table#metadata, table#mods {
/*********
-** Upload popup
+** Upload form
*********/
-#upload-area .popup,
-#upload-area #uploader {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, .33);
- z-index: 2;
- display: none;
- padding: 5px;
-}
-
-#upload-area #uploader:after {
- content: attr(data-text);
- display: block;
- width: 100px;
- height: 24px;
- line-height: 25px;
- border: 1px solid #000;
- background: #fff;
- position: absolute;
- top: 50%;
- left: 50%;
- margin: -12px -50px 0 0;
- font-size: 18px;
- font-weight: bold;
- text-align: center;
- border-radius: 5px;
-}
-
-#upload-area .popup h1 {
- position: absolute;
- top: 10%;
- left: 50%;
- margin-left: -150px;
- text-align: center;
- width: 300px;
- border: 1px solid #008;
- border-radius: 5px;
- background: #fff;
- font-family: sans-serif;
- font-size: 40px;
- margin-top: -25px;
- z-index: 10;
- border-bottom: 0;
+#os-list {
+ list-style: none;
}
-#upload-area .frame {
- margin: auto;
- margin-top: 25px;
- padding: 2em;
- position: absolute;
- top: 10%;
- left: 10%;
- right: 10%;
- bottom: 10%;
- padding-bottom: 30px;
- background: #FFF;
- border-radius: 5px;
- border: 1px solid #008;
-}
-
-#upload-area #cancel {
- border: 1px solid #880000;
- background-color: #fcc;
-}
-
-#upload-area #submit {
- border: 1px solid #008800;
- background-color: #cfc;
-}
-
-#upload-area #submit:hover {
- background-color: #efe;
+div[data-os] {
+ display: none;
}
-#upload-area #input {
+#input {
width: 100%;
- height: 30em;
+ height: 20em;
max-height: 70%;
margin: auto;
box-sizing: border-box;
@@ -346,3 +275,13 @@ table#metadata, table#mods {
outline: none;
box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
}
+
+#submit {
+ font-size: 1.5em;
+ border-radius: 5px;
+ outline: none;
+ box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
+ cursor: pointer;
+ border: 1px solid #008800;
+ background-color: #cfc;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css
index d1fa49e0..57eeee88 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/main.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/main.css
@@ -28,7 +28,8 @@ h2 {
h3 {
font-size: 1.2em;
border-bottom: 1px solid #AAA;
- width: 50%;
+ width: 55em;
+ max-width: 100%;
}
a {
@@ -44,6 +45,8 @@ a {
#content {
min-height: 140px;
+ width: calc(100% - 2em);
+ max-width: 60em;
padding: 0 1em 1em 1em;
border-left: 1px solid #CCC;
background: #FFF;
@@ -51,7 +54,7 @@ a {
}
#content p {
- max-width: 55em;
+ max-width: 100%;
}
.section {
@@ -105,3 +108,34 @@ a {
#footer a {
color: #669;
}
+
+/* mobile fixes */
+@media (min-width: 1020px) and (max-width: 1199px) {
+ #sidebar {
+ width: 7em;
+ background: none;
+ }
+
+ #content-column {
+ left: 7em;
+ }
+}
+
+@media (max-width: 1019px) {
+ h1 {
+ margin-top: 0;
+ }
+
+ #sidebar {
+ margin-top: 0;
+ width: auto;
+ min-height: 0;
+ background: none;
+ }
+
+ #content-column {
+ position: inherit;
+ top: inherit;
+ left: inherit;
+ }
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png b/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png
new file mode 100644
index 00000000..6c30ca36
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png
Binary files differ
diff --git a/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png
new file mode 100644
index 00000000..10c66712
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png
Binary files differ
diff --git a/src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.png b/src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.png
new file mode 100644
index 00000000..f359146c
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.png
Binary files differ
diff --git a/src/SMAPI.Web/wwwroot/Content/images/pufferchick.png b/src/SMAPI.Web/wwwroot/Content/images/pufferchick.png
new file mode 100644
index 00000000..1de9cf47
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/images/pufferchick.png
Binary files differ
diff --git a/src/SMAPI.Web/wwwroot/Content/js/index.js b/src/SMAPI.Web/wwwroot/Content/js/index.js
new file mode 100644
index 00000000..d0734b02
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/js/index.js
@@ -0,0 +1,34 @@
+$(document).ready(function () {
+ /* enable pufferchick */
+ var pufferchick = $("#pufferchick");
+ $(".cta-dropdown").hover(
+ function () {
+ pufferchick.attr("src", "Content/images/pufferchick-cool.png");
+ },
+ function () {
+ pufferchick.attr("src", "Content/images/pufferchick.png");
+ }
+ );
+
+ /* enable download dropdowns */
+ $(".cta-dropdown a.download").each(function(i, button) {
+ button = $(button);
+ var wrapper = button.parent(".cta-dropdown");
+ var button = wrapper.find(".download");
+ var dropdownContent = wrapper.find(".dropdown-content");
+
+ $(window).on("click", function(e) {
+ var target = $(e.target);
+
+ // toggle dropdown on button click
+ if (target.is(button) || $.contains(button.get(0), target.get(0))) {
+ e.preventDefault();
+ dropdownContent.toggle();
+ }
+
+ // else hide dropdown
+ else
+ dropdownContent.hide();
+ });
+ });
+});
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index c4a35e96..0c654205 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -39,11 +39,17 @@ smapi.logParser = function (data, sectionUrl) {
}
},
methods: {
- toggleLevel: function(id) {
+ toggleLevel: function (id) {
+ if (!data.enableFilters)
+ return;
+
this.showLevels[id] = !this.showLevels[id];
},
toggleMod: function (id) {
+ if (!data.enableFilters)
+ return;
+
var curShown = this.showMods[id];
// first filter: only show this by default
@@ -64,6 +70,9 @@ smapi.logParser = function (data, sectionUrl) {
},
showAllMods: function () {
+ if (!data.enableFilters)
+ return;
+
for (var key in this.showMods) {
if (this.showMods.hasOwnProperty(key)) {
this.showMods[key] = true;
@@ -73,6 +82,9 @@ smapi.logParser = function (data, sectionUrl) {
},
hideAllMods: function () {
+ if (!data.enableFilters)
+ return;
+
for (var key in this.showMods) {
if (this.showMods.hasOwnProperty(key)) {
this.showMods[key] = false;
@@ -90,88 +102,50 @@ smapi.logParser = function (data, sectionUrl) {
/**********
** Upload form
*********/
- var error = $("#error");
-
- $("#upload-button").on("click", function(e) {
- e.preventDefault();
-
- $("#input").val("");
- $("#popup-upload").fadeIn();
- });
-
- var closeUploadPopUp = function() {
- $("#popup-upload").fadeOut(400);
- };
-
- $("#popup-upload").on({
- 'dragover dragenter': function(e) {
- e.preventDefault();
- e.stopPropagation();
- },
- 'drop': function(e) {
- $("#uploader").attr("data-text", "Reading...");
- $("#uploader").show();
- var dataTransfer = e.originalEvent.dataTransfer;
- if (dataTransfer && dataTransfer.files.length) {
- e.preventDefault();
- e.stopPropagation();
- var file = dataTransfer.files[0];
- var reader = new FileReader();
- reader.onload = $.proxy(function(file, $input, event) {
- $input.val(event.target.result);
- $("#uploader").fadeOut();
- $("#submit").click();
- }, this, file, $("#input"));
- reader.readAsText(file);
- }
- },
- 'click': function(e) {
- if (e.target.id === "popup-upload")
- closeUploadPopUp();
+ var input = $("#input");
+ if (input.length) {
+ // get elements
+ var systemOptions = $("input[name='os']");
+ var systemInstructions = $("div[data-os]");
+ var submit = $("#submit");
+
+ // instruction OS chooser
+ var chooseSystem = function() {
+ systemInstructions.hide();
+ systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show();
}
- });
-
- $("#submit").on("click", function() {
- $("#popup-upload").fadeOut();
- var paste = $("#input").val();
- if (paste) {
- //memory = "";
- $("#uploader").attr("data-text", "Saving...");
- $("#uploader").fadeIn();
- $
- .ajax({
- type: "POST",
- url: sectionUrl + "/save",
- data: JSON.stringify(paste),
- contentType: "application/json" // sent to API
- })
- .fail(function(xhr, textStatus) {
- $("#uploader").fadeOut();
- error.html('<h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br />&nbsp;<p>Stage: Upload</p>Error: ' + textStatus + ': ' + xhr.responseText + "<hr /><pre>" + $("#input").val() + "</pre>");
- })
- .then(function(data) {
- $("#uploader").fadeOut();
- if (!data.success)
- error.html('<h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br />&nbsp;<p>Stage: Upload</p>Error: ' + data.error + "<hr /><pre>" + $("#input").val() + "</pre>");
- else
- location.href = (sectionUrl.replace(/\/$/, "") + "/" + data.id);
- });
- } else {
- alert("Unable to parse log, the input is empty!");
- $("#uploader").fadeOut();
+ systemOptions.on("click", chooseSystem);
+ chooseSystem();
+
+ // disable submit if it's empty
+ var toggleSubmit = function()
+ {
+ var hasText = !!input.val().trim();
+ submit.prop("disabled", !hasText);
}
- });
+ input.on("input", toggleSubmit);
+ toggleSubmit();
- $(document).on("keydown", function(e) {
- if (e.which === 27) {
- if ($("#popup-upload").css("display") !== "none" && $("#popup-upload").css("opacity") === 1) {
- closeUploadPopUp();
+ // drag & drop file
+ input.on({
+ 'dragover dragenter': function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ 'drop': function(e) {
+ var dataTransfer = e.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.files.length) {
+ e.preventDefault();
+ e.stopPropagation();
+ var file = dataTransfer.files[0];
+ var reader = new FileReader();
+ reader.onload = $.proxy(function(file, $input, event) {
+ $input.val(event.target.result);
+ toggleSubmit();
+ }, this, file, $("#input"));
+ reader.readAsText(file);
+ }
}
- }
- });
- $("#cancel").on("click", closeUploadPopUp);
-
- if (data.showPopup)
- $("#popup-upload").fadeIn();
-
+ });
+ }
};
diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json
new file mode 100644
index 00000000..e72efb39
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json
@@ -0,0 +1,1676 @@
+{
+ /**
+ * Metadata about some SMAPI mods used in compatibility, update, and dependency checks. This
+ * field shouldn't be edited by players in most cases.
+ *
+ * Standard fields
+ * ===============
+ * The predefined fields are documented below (only 'ID' is required). Each entry's key is the
+ * default display name for the mod if one isn't available (e.g. in dependency checks).
+ *
+ * - ID: the mod's latest unique ID (if any).
+ *
+ * - FormerIDs: uniquely identifies the mod across multiple versions, and supports matching
+ * other fields if no ID was specified. This doesn't include the latest ID, if any. Multiple
+ * variants can be separated with '|'.
+ *
+ * - MapLocalVersions and MapRemoteVersions correct local manifest versions and remote versions
+ * during update checks. For example, if the API returns version '1.1-1078' where '1078' is
+ * intended to be a build number, MapRemoteVersions can map it to '1.1' when comparing to the
+ * mod's current version. This is only meant to support legacy mods with injected update keys.
+ *
+ * Versioned metadata
+ * ==================
+ * Each record can also specify extra metadata using the field keys below.
+ *
+ * Each key consists of a field name prefixed with any combination of version range and 'Default',
+ * separated by pipes (whitespace trimmed). For example, 'UpdateKey' will always override,
+ * 'Default | UpdateKey' will only override if the mod has no update keys, and
+ * '~1.1 | Default | Name' will do the same up to version 1.1.
+ *
+ * The version format is 'min~max' (where either side can be blank for unbounded), or a single
+ * version number.
+ *
+ * These are the valid field names:
+ *
+ * - UpdateKey: the update key to set in the mod's manifest. This is used to enable update
+ * checks for older mods that haven't been updated to use it yet.
+ *
+ * - Status: overrides compatibility checks. The possible values are Obsolete (SMAPI won't load
+ * it because the mod should no longer be used), AssumeBroken (SMAPI won't load it because
+ * the specified version isn't compatible), or AssumeCompatible (SMAPI will try to load it
+ * even if it detects incompatible code).
+ *
+ * Note that this shouldn't be set to 'AssumeBroken' if SMAPI can detect the incompatibility
+ * automatically, since that hides the details from trace logs.
+ *
+ * - StatusReasonPhrase: a message to show to the player explaining why the mod can't be loaded
+ * (if applicable). If blank, will default to a generic not-compatible message.
+ *
+ * - AlternativeUrl: a URL where the player can find an unofficial update or alternative if the
+ * mod is no longer compatible.
+ */
+ "ModData": {
+ "AccessChestAnywhere": {
+ "ID": "AccessChestAnywhere",
+ "MapLocalVersions": { "1.1-1078": "1.1" },
+ "Default | UpdateKey": "Nexus:257",
+ "~1.1 | Status": "AssumeBroken"
+ },
+
+ "Adjust Artisan Prices": {
+ "ID": "ThatNorthernMonkey.AdjustArtisanPrices",
+ "FormerIDs": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", // changed in 0.0.2-pathoschild-update
+ "MapRemoteVersions": { "0.01": "0.0.1" },
+ "Default | UpdateKey": "Chucklefish:3532"
+ },
+
+ "Adjust Monster": {
+ "ID": "mmanlapat.AdjustMonster",
+ "Default | UpdateKey": "Nexus:1161"
+ },
+
+ "Advanced Location Loader": {
+ "ID": "Entoarox.AdvancedLocationLoader",
+ "~1.3.7 | UpdateKey": "Chucklefish:3619" // only enable update checks up to 1.3.7 by request (has its own update-check feature)
+ },
+
+ "Adventure Shop Inventory": {
+ "ID": "HammurabiAdventureShopInventory",
+ "Default | UpdateKey": "Chucklefish:4608"
+ },
+
+ "AgingMod": {
+ "ID": "skn.AgingMod",
+ "Default | UpdateKey": "Nexus:1129",
+ "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "All Crops All Seasons": {
+ "ID": "cantorsdust.AllCropsAllSeasons",
+ "FormerIDs": "29ee8246-d67b-4242-a340-35a9ae0d5dd7 | community.AllCropsAllSeasons", // changed in 1.3 and 1.5
+ "Default | UpdateKey": "Nexus:170"
+ },
+
+ "All Professions": {
+ "ID": "cantorsdust.AllProfessions",
+ "FormerIDs": "8c37b1a7-4bfb-4916-9d8a-9533e6363ea3 | community.AllProfessions", // changed in 1.2 and 1.3.1
+ "Default | UpdateKey": "Nexus:174"
+ },
+
+ "Almighty Farming Tool": {
+ "ID": "439",
+ "MapRemoteVersions": {
+ "1.21": "1.2.1",
+ "1.22-unofficial.3.mizzion": "1.2.2-unofficial.3.mizzion"
+ },
+ "Default | UpdateKey": "Nexus:439"
+ },
+
+ "Animal Husbandry": {
+ "ID": "DIGUS.ANIMALHUSBANDRYMOD",
+ "FormerIDs": "DIGUS.BUTCHER", // changed in 2.0.1
+ "Default | UpdateKey": "Nexus:1538"
+ },
+
+ "Animal Mood Fix": {
+ "ID": "GPeters-AnimalMoodFix",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2."
+ },
+
+ "Animal Sitter": {
+ "ID": "jwdred.AnimalSitter",
+ "Default | UpdateKey": "Nexus:581",
+ "~1.0.8 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Arcade Pong": {
+ "ID": "Platonymous.ArcadePong",
+ "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals
+ },
+
+ "A Tapper's Dream": {
+ "ID": "ddde5195-8f85-4061-90cc-0d4fd5459358",
+ "Default | UpdateKey": "Nexus:260"
+ },
+
+ "Auto Animal Doors": {
+ "ID": "AaronTaggart.AutoAnimalDoors",
+ "Default | UpdateKey": "Nexus:1019"
+ },
+
+ "Auto-Eat": {
+ "ID": "Permamiss.AutoEat",
+ "FormerIDs": "BALANCEMOD_AutoEat", // changed in 1.1.1
+ "Default | UpdateKey": "Nexus:643"
+ },
+
+ "AutoFish": {
+ "ID": "WhiteMind.AF",
+ "Default | UpdateKey": "Nexus:1895"
+ },
+
+ "AutoGate": {
+ "ID": "AutoGate",
+ "Default | UpdateKey": "Nexus:820"
+ },
+
+ "Automate": {
+ "ID": "Pathoschild.Automate",
+ "Default | UpdateKey": "Nexus:1063",
+ "~1.10-beta.7 | Status": "AssumeBroken" // broke in SDV 1.3.20
+ },
+
+ "Automated Doors": {
+ "ID": "azah.automated-doors",
+ "FormerIDs": "1abcfa07-2cf4-4dc3-a6e9-6068b642112b", // changed in 1.4.1
+ "Default | UpdateKey": "GitHub:azah/AutomatedDoors" // added in 1.4.2
+ },
+
+ "AutoSpeed": {
+ "ID": "Omegasis.AutoSpeed",
+ "Default | UpdateKey": "Nexus:443" // added in 1.4.1
+ },
+
+ "Basic Sprinklers Improved": {
+ "ID": "lrsk_sdvm_bsi.0117171308",
+ "MapRemoteVersions": { "1.0.2": "1.0.1-release" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:833"
+ },
+
+ "Better Hay": {
+ "ID": "cat.betterhay",
+ "Default | UpdateKey": "Nexus:1430"
+ },
+
+ "Better Quality More Seasons": {
+ "ID": "SB_BQMS",
+ "Default | UpdateKey": "Nexus:935"
+ },
+
+ "Better Quarry": {
+ "ID": "BetterQuarry",
+ "Default | UpdateKey": "Nexus:771"
+ },
+
+ "Better Ranching": {
+ "ID": "BetterRanching",
+ "Default | UpdateKey": "Nexus:859"
+ },
+
+ "Better Shipping Box": {
+ "ID": "Kithio:BetterShippingBox",
+ "MapLocalVersions": { "1.0.1": "1.0.2" },
+ "Default | UpdateKey": "Chucklefish:4302"
+ },
+
+ "Better Sprinklers": {
+ "ID": "Speeder.BetterSprinklers",
+ "FormerIDs": "SPDSprinklersMod", // changed in 2.3
+ "Default | UpdateKey": "Nexus:41"
+ },
+
+ "Billboard Anywhere": {
+ "ID": "Omegasis.BillboardAnywhere",
+ "Default | UpdateKey": "Nexus:492" // added in 1.4.1
+ },
+
+ "Birthday Mail": {
+ "ID": "KathrynHazuka.BirthdayMail",
+ "FormerIDs": "005e02dc-d900-425c-9c68-1ff55c5a295d", // changed in 1.2.3-pathoschild-update
+ "Default | UpdateKey": "Nexus:276",
+ "MapRemoteVersions": { "1.3.1": "1.3" } // manifest not updated
+ },
+
+ "Breed Like Rabbits": {
+ "ID": "dycedarger.breedlikerabbits",
+ "Default | UpdateKey": "Nexus:948"
+ },
+
+ "Build Endurance": {
+ "ID": "Omegasis.BuildEndurance",
+ "Default | UpdateKey": "Nexus:445" // added in 1.4.1
+ },
+
+ "Build Health": {
+ "ID": "Omegasis.BuildHealth",
+ "Default | UpdateKey": "Nexus:446" // added in 1.4.1
+ },
+
+ "Buy Cooking Recipes": {
+ "ID": "Denifia.BuyRecipes",
+ "Default | UpdateKey": "Nexus:1126" // added in 1.0.1 (2017-10-04)
+ },
+
+ "Buy Back Collectables": {
+ "ID": "Omegasis.BuyBackCollectables",
+ "FormerIDs": "BuyBackCollectables", // changed in 1.4
+ "Default | UpdateKey": "Nexus:507" // added in 1.4.1
+ },
+
+ "Carry Chest": {
+ "ID": "spacechase0.CarryChest",
+ "Default | UpdateKey": "Nexus:1333"
+ },
+
+ "Casks Anywhere": {
+ "ID": "CasksAnywhere",
+ "MapLocalVersions": { "1.1-alpha": "1.1" },
+ "Default | UpdateKey": "Nexus:878"
+ },
+
+ "Categorize Chests": {
+ "ID": "CategorizeChests",
+ "Default | UpdateKey": "Nexus:1300",
+ "~1.4.3-unofficial.2.mizzion | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.18 (in-game errors)
+ },
+
+ "Chefs Closet": {
+ "ID": "Duder.ChefsCloset",
+ "MapLocalVersions": { "1.3-1": "1.3" },
+ "Default | UpdateKey": "Nexus:1030"
+ },
+
+ "Chest Label System": {
+ "ID": "Speeder.ChestLabel",
+ "FormerIDs": "SPDChestLabel", // changed in 1.5.1-pathoschild-update
+ "Default | UpdateKey": "Nexus:242"
+ },
+
+ "Chest Pooling": {
+ "ID": "mralbobo.ChestPooling",
+ "Default | UpdateKey": "GitHub:mralbobo/stardew-chest-pooling"
+ },
+
+ "Chests Anywhere": {
+ "ID": "Pathoschild.ChestsAnywhere",
+ "FormerIDs": "ChestsAnywhere", // changed in 1.9
+ "Default | UpdateKey": "Nexus:518",
+ "~1.12.4 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "CJB Automation": {
+ "ID": "CJBAutomation",
+ "Default | UpdateKey": "Nexus:211",
+ "~1.4 | Status": "AssumeBroken", // broke in SDV 1.2
+ "~1.4 | AlternativeUrl": "http://www.nexusmods.com/stardewvalley/mods/1063"
+ },
+
+ "CJB Cheats Menu": {
+ "ID": "CJBok.CheatsMenu",
+ "FormerIDs": "CJBCheatsMenu", // changed in 1.14
+ "Default | UpdateKey": "Nexus:4",
+ "~1.18-beta | Status": "AssumeBroken" // broke in SDV 1.3, first beta causes significant friendship bugs
+ },
+
+ "CJB Item Spawner": {
+ "ID": "CJBok.ItemSpawner",
+ "FormerIDs": "CJBItemSpawner", // changed in 1.7
+ "Default | UpdateKey": "Nexus:93",
+ "~1.10 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "CJB Show Item Sell Price": {
+ "ID": "CJBok.ShowItemSellPrice",
+ "FormerIDs": "CJBShowItemSellPrice", // changed in 1.7
+ "Default | UpdateKey": "Nexus:5",
+ "~1.8 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Clean Farm": {
+ "ID": "tstaples.CleanFarm",
+ "Default | UpdateKey": "Nexus:794"
+ },
+
+ "Climates of Ferngill": {
+ "ID": "KoihimeNakamura.ClimatesOfFerngill",
+ "Default | UpdateKey": "Nexus:604"
+ },
+
+ "Coal Regen": {
+ "ID": "Blucifer.CoalRegen",
+ "Default | UpdateKey": "Nexus:1664"
+ },
+
+ "Cobalt": {
+ "ID": "spacechase0.Cobalt",
+ "MapRemoteVersions": { "1.1.3": "1.1.2" } // not updated in manifest
+ },
+
+ "Cold Weather Haley": {
+ "ID": "LordXamon.ColdWeatherHaleyPRO",
+ "Default | UpdateKey": "Nexus:1169",
+ "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Colored Chests": {
+ "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1."
+ },
+
+ "Combat with Farm Implements": {
+ "ID": "SPDFarmingImplementsInCombat",
+ "Default | UpdateKey": "Nexus:313"
+ },
+
+ "Community Bundle Item Tooltip": {
+ "ID": "musbah.bundleTooltip",
+ "Default | UpdateKey": "Nexus:1329"
+ },
+
+ "Concentration on Farming": {
+ "ID": "punyo.ConcentrationOnFarming",
+ "Default | UpdateKey": "Nexus:1445"
+ },
+
+ "Configurable Machines": {
+ "ID": "21da6619-dc03-4660-9794-8e5b498f5b97",
+ "MapLocalVersions": { "1.2-beta": "1.2" },
+ "Default | UpdateKey": "Nexus:280"
+ },
+
+ "Configurable Shipping Dates": {
+ "ID": "ConfigurableShippingDates",
+ "Default | UpdateKey": "Nexus:675",
+ "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Content Patcher": {
+ "ID": "Pathoschild.ContentPatcher",
+ "Default | UpdateKey": "Nexus:1915",
+ "~1.4-beta.5 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.18 (in-game errors)
+ },
+
+ "Cooking Skill": {
+ "ID": "spacechase0.CookingSkill",
+ "FormerIDs": "CookingSkill", // changed in 1.0.4–6
+ "Default | UpdateKey": "Nexus:522"
+ },
+
+ "CrabNet": {
+ "ID": "jwdred.CrabNet",
+ "Default | UpdateKey": "Nexus:584"
+ },
+
+ "Crafting Counter": {
+ "ID": "lolpcgaming.CraftingCounter",
+ "Default | UpdateKey": "Nexus:1585",
+ "MapRemoteVersions": { "1.1": "1.0" } // not updated in manifest
+ },
+
+ "Current Location": {
+ "ID": "CurrentLocation102120161203",
+ "Default | UpdateKey": "Nexus:638"
+ },
+
+ "Custom Asset Modifier": {
+ "ID": "Omegasis.CustomAssetModifier",
+ "Default | UpdateKey": "1836"
+ },
+
+ "Custom Critters": {
+ "ID": "spacechase0.CustomCritters",
+ "Default | UpdateKey": "Nexus:1255"
+ },
+
+ "Custom Crops": {
+ "ID": "spacechase0.CustomCrops",
+ "Default | UpdateKey": "Nexus:1592"
+ },
+
+ "Custom Element Handler": {
+ "ID": "Platonymous.CustomElementHandler",
+ "Default | UpdateKey": "Nexus:1068" // added in 1.3.1
+ },
+
+ "Custom Farming Redux": {
+ "ID": "Platonymous.CustomFarming",
+ "Default | UpdateKey": "Nexus:991" // added in 0.6.1
+ },
+
+ "Custom Farming Automate Bridge": {
+ "ID": "Platonymous.CFAutomate",
+ "~1.0.1 | Status": "AssumeBroken", // no longer compatible with Automate
+ "~1.0.1 | AlternativeUrl": "https://www.nexusmods.com/stardewvalley/mods/991"
+ },
+
+ "Custom Farm Types": {
+ "ID": "spacechase0.CustomFarmTypes",
+ "Default | UpdateKey": "Nexus:1140"
+ },
+
+ "Custom Furniture": {
+ "ID": "Platonymous.CustomFurniture",
+ "Default | UpdateKey": "Nexus:1254" // added in 0.4.1
+ },
+
+ "Customize Exterior": {
+ "ID": "spacechase0.CustomizeExterior",
+ "FormerIDs": "CustomizeExterior", // changed in 1.0.3
+ "Default | UpdateKey": "Nexus:1099",
+ "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Customizable Cart Redux": {
+ "ID": "KoihimeNakamura.CCR",
+ "MapLocalVersions": { "1.1-20170917": "1.1" },
+ "Default | UpdateKey": "Nexus:1402"
+ },
+
+ "Customizable Traveling Cart Days": {
+ "ID": "TravelingCartYyeahdude",
+ "Default | UpdateKey": "Nexus:567"
+ },
+
+ "Custom Linens": {
+ "ID": "Mevima.CustomLinens",
+ "MapRemoteVersions": { "1.1": "1.0" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:1027"
+ },
+
+ "Custom NPC": {
+ "ID": "Platonymous.CustomNPC",
+ "Default | UpdateKey": "Nexus:1607"
+ },
+
+ "Custom Shops Redux": {
+ "ID": "Omegasis.CustomShopReduxGui",
+ "Default | UpdateKey": "Nexus:1378" // added in 1.4.1
+ },
+
+ "Custom TV": {
+ "ID": "Platonymous.CustomTV",
+ "Default | UpdateKey": "Nexus:1139" // added in 1.0.6
+ },
+
+ "Daily Luck Message": {
+ "ID": "Schematix.DailyLuckMessage",
+ "Default | UpdateKey": "Nexus:1327"
+ },
+
+ "Daily News": {
+ "ID": "bashNinja.DailyNews",
+ "Default | UpdateKey": "Nexus:1141",
+ "~1.2 | Status": "AssumeBroken" // broke in Stardew Valley 1.3 (or depends on CustomTV which broke)
+ },
+
+ "Daily Quest Anywhere": {
+ "ID": "Omegasis.DailyQuestAnywhere",
+ "FormerIDs": "DailyQuest", // changed in 1.4
+ "Default | UpdateKey": "Nexus:513" // added in 1.4.1
+ },
+
+ "Data Maps": {
+ "ID": "Pathoschild.DataMaps",
+ "Default | UpdateKey": "Nexus:1691",
+ "~1.4 | Status": "AssumeBroken" // replaced by Data Layers
+ },
+
+ "Debug Mode": {
+ "ID": "Pathoschild.DebugMode",
+ "FormerIDs": "Pathoschild.Stardew.DebugMode", // changed in 1.4
+ "Default | UpdateKey": "Nexus:679",
+ "~1.8 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Did You Water Your Crops?": {
+ "ID": "Nishtra.DidYouWaterYourCrops",
+ "Default | UpdateKey": "Nexus:1583"
+ },
+
+ "Dynamic Checklist": {
+ "ID": "gunnargolf.DynamicChecklist",
+ "Default | UpdateKey": "Nexus:1145" // added in 1.0.1-pathoschild-update
+ },
+
+ "Dynamic Horses": {
+ "ID": "Bpendragon-DynamicHorses",
+ "MapRemoteVersions": { "1.2": "1.1-release" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:874"
+ },
+
+ "Dynamic Machines": {
+ "ID": "DynamicMachines",
+ "MapLocalVersions": { "1.1": "1.1.1" },
+ "Default | UpdateKey": "Nexus:374",
+ "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Dynamic NPC Sprites": {
+ "ID": "BashNinja.DynamicNPCSprites",
+ "Default | UpdateKey": "Nexus:1183"
+ },
+
+ "Easier Farming": {
+ "ID": "cautiouswafffle.EasierFarming",
+ "Default | UpdateKey": "Nexus:1426"
+ },
+
+ "Empty Hands": {
+ "ID": "QuicksilverFox.EmptyHands",
+ "Default | UpdateKey": "Nexus:1176" // added in 1.0.1-pathoschild-update
+ },
+
+ "Enemy Health Bars": {
+ "ID": "Speeder.HealthBars",
+ "FormerIDs": "SPDHealthBar", // changed in 1.7.1-pathoschild-update
+ "Default | UpdateKey": "Nexus:193"
+ },
+
+ "Entoarox Framework": {
+ "ID": "Entoarox.EntoaroxFramework",
+ "FormerIDs": "eacdb74b-4080-4452-b16b-93773cda5cf9", // changed in ???
+ "~2.0.6 | UpdateKey": "Chucklefish:4228", // only enable update checks up to 2.0.6 by request (has its own update-check feature)
+ "~2.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.5 (error reflecting into SMAPI internals)
+ },
+
+ "Expanded Fridge": {
+ "ID": "Uwazouri.ExpandedFridge",
+ "Default | UpdateKey": "Nexus:1191"
+ },
+
+ "Experience Bars": {
+ "ID": "spacechase0.ExperienceBars",
+ "FormerIDs": "ExperienceBars", // changed in 1.0.2
+ "Default | UpdateKey": "Nexus:509"
+ },
+
+ "Extended Bus System": {
+ "ID": "ExtendedBusSystem",
+ "Default | UpdateKey": "Chucklefish:4373"
+ },
+
+ "Extended Fridge": {
+ "ID": "Crystalmir.ExtendedFridge",
+ "FormerIDs": "Mystra007ExtendedFridge", // changed in 1.0.1
+ "Default | UpdateKey": "Nexus:485"
+ },
+
+ "Extended Greenhouse": {
+ "ID": "ExtendedGreenhouse",
+ "Default | UpdateKey": "Chucklefish:4303",
+ "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Extended Minecart": {
+ "ID": "Entoarox.ExtendedMinecart",
+ "~1.7.1 | UpdateKey": "Chucklefish:4359" // only enable update checks up to 1.7.1 by request (has its own update-check feature)
+ },
+
+ "Extended Reach": {
+ "ID": "spacechase0.ExtendedReach",
+ "Default | UpdateKey": "Nexus:1493"
+ },
+
+ "Fall 28 Snow Day": {
+ "ID": "Omegasis.Fall28SnowDay",
+ "Default | UpdateKey": "Nexus:486", // added in 1.4.1
+ "~1.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0, and update for SMAPI 2.0 doesn't do anything
+ },
+
+ "Farm Automation Unofficial: Item Collector": {
+ "ID": "Maddy99.FarmAutomation.ItemCollector",
+ "~0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Farm Expansion": {
+ "ID": "Advize.FarmExpansion",
+ "FormerIDs": "3888bdfd-73f6-4776-8bb7-8ad45aea1915 | AdvizeFarmExpansionMod-2-0 | AdvizeFarmExpansionMod-2-0-5", // changed in 2.0, 2.0.5, and 3.0
+ "Default | UpdateKey": "Nexus:130"
+ },
+
+ "Fast Animations": {
+ "ID": "Pathoschild.FastAnimations",
+ "Default | UpdateKey": "Nexus:1089",
+ "~1.5 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Faster Grass": {
+ "ID": "IceGladiador.FasterGrass",
+ "Default | UpdateKey": "Nexus:1772"
+ },
+
+ "Faster Paths": {
+ "ID": "Entoarox.FasterPaths",
+ "FormerIDs": "615f85f8-5c89-44ee-aecc-c328f172e413", // changed in 1.3
+ "~1.3.3 | UpdateKey": "Chucklefish:3641" // only enable update checks up to 1.3.3 by request (has its own update-check feature)
+ },
+
+ "Fishing Adjust": {
+ "ID": "shuaiz.FishingAdjustMod",
+ "Default | UpdateKey": "Nexus:1350",
+ "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)'
+ },
+
+ "Fishing Tuner Redux": {
+ "ID": "HammurabiFishingTunerRedux",
+ "Default | UpdateKey": "Chucklefish:4578"
+ },
+
+ "Fixed Secret Woods Debris": {
+ "ID": "f4iTh.WoodsDebrisFix",
+ "Default | UpdateKey": "Nexus:1941"
+ },
+
+ "Fix Scythe Exp": {
+ "ID": "bcmpinc.FixScytheExp",
+ "~0.2 | Status": "AssumeBroken" // Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator.
+ },
+
+ "Flower Color Picker": {
+ "ID": "spacechase0.FlowerColorPicker",
+ "Default | UpdateKey": "Nexus:1229"
+ },
+
+ "Forage at the Farm": {
+ "ID": "Nishtra.ForageAtTheFarm",
+ "FormerIDs": "ForageAtTheFarm", // changed in <=1.6
+ "Default | UpdateKey": "Nexus:673"
+ },
+
+ "Furniture Anywhere": {
+ "ID": "Entoarox.FurnitureAnywhere",
+ "~1.1.5 | UpdateKey": "Chucklefish:4324" // only enable update checks up to 1.1.5 by request (has its own update-check feature)
+ },
+
+ "Game Reminder": {
+ "ID": "mmanlapat.GameReminder",
+ "Default | UpdateKey": "Nexus:1153"
+ },
+
+ "Gate Opener": {
+ "ID": "mralbobo.GateOpener",
+ "Default | UpdateKey": "GitHub:mralbobo/stardew-gate-opener"
+ },
+
+ "GenericShopExtender": {
+ "ID": "GenericShopExtender",
+ "Default | UpdateKey": "Nexus:814" // added in 0.1.3
+ },
+
+ "Geode Info Menu": {
+ "ID": "cat.geodeinfomenu",
+ "Default | UpdateKey": "Nexus:1448"
+ },
+
+ "Get Dressed": {
+ "ID": "Advize.GetDressed",
+ "Default | UpdateKey": "Nexus:331"
+ },
+
+ "Giant Crop Ring": {
+ "ID": "cat.giantcropring",
+ "Default | UpdateKey": "Nexus:1182"
+ },
+
+ "Gift Taste Helper": {
+ "ID": "tstaples.GiftTasteHelper",
+ "FormerIDs": "8008db57-fa67-4730-978e-34b37ef191d6", // changed in 2.5
+ "Default | UpdateKey": "Nexus:229"
+ },
+
+ "Grandfather's Gift": {
+ "ID": "ShadowDragon.GrandfathersGift",
+ "Default | UpdateKey": "Nexus:985"
+ },
+
+ "Happy Animals": {
+ "ID": "HappyAnimals",
+ "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Happy Birthday (Omegasis)": {
+ "ID": "Omegasis.HappyBirthday",
+ "Default | UpdateKey": "Nexus:520" // added in 1.4.1
+ },
+
+ "Hardcore Mines": {
+ "ID": "kibbe.hardcore_mines",
+ "Default | UpdateKey": "Nexus:1674"
+ },
+
+ "Harp of Yoba Redux": {
+ "ID": "Platonymous.HarpOfYobaRedux",
+ "Default | UpdateKey": "Nexus:914" // added in 2.0.3
+ },
+
+ "Harvest Moon Witch Princess": {
+ "ID": "Sasara.WitchPrincess",
+ "Default | UpdateKey": "Nexus:1157"
+ },
+
+ "Harvest With Scythe": {
+ "ID": "965169fd-e1ed-47d0-9f12-b104535fb4bc",
+ "Default | UpdateKey": "Nexus:236"
+ },
+
+ "Horse Whistle (icepuente)": {
+ "ID": "icepuente.HorseWhistle",
+ "Default | UpdateKey": "Nexus:1131",
+ "~1.1.2-unofficial.1-pathoschild | Status": "AssumeBroken" // causes significant lag, fixed in unofficial.2
+ },
+
+ "Hunger (Yyeadude)": {
+ "ID": "HungerYyeadude",
+ "Default | UpdateKey": "Nexus:613"
+ },
+
+ "Hunger for Food (Tigerle)": {
+ "ID": "HungerForFoodByTigerle",
+ "Default | UpdateKey": "Nexus:810",
+ "~0.1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Hunger Mod (skn)": {
+ "ID": "skn.HungerMod",
+ "MapRemoteVersions": { "1.2.1": "1.0" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:1127"
+ },
+
+ "Idle Pause": {
+ "ID": "Veleek.IdlePause",
+ "MapRemoteVersions": { "1.2": "1.1" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:1092"
+ },
+
+ "Improved Quality of Life": {
+ "ID": "Demiacle.ImprovedQualityOfLife",
+ "Default | UpdateKey": "Nexus:1025",
+ "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Instant Geode": {
+ "ID": "InstantGeode",
+ "~1.12 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Instant Grow Trees": {
+ "ID": "cantorsdust.InstantGrowTrees",
+ "FormerIDs": "dc50c58b-c7d8-4e60-86cc-e27b5d95ee59 | community.InstantGrowTrees", // changed in 1.2 and 1.3.1
+ "Default | UpdateKey": "Nexus:173"
+ },
+
+ "Interaction Helper": {
+ "ID": "HammurabiInteractionHelper",
+ "Default | UpdateKey": "Chucklefish:4640" // added in 1.0.4-pathoschild-update
+ },
+
+ "Item Auto Stacker": {
+ "ID": "cat.autostacker",
+ "MapRemoteVersions": { "1.0.1": "1.0" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:1184"
+ },
+
+ "Json Assets": {
+ "ID": "spacechase0.JsonAssets",
+ "Default | UpdateKey": "Nexus:1720"
+ },
+
+ "Junimo Farm": {
+ "ID": "Platonymous.JunimoFarm",
+ "MapRemoteVersions": { "1.1.2": "1.1.1" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:984" // added in 1.1.3
+ },
+
+ "Less Strict Over-Exertion (AntiExhaustion)": {
+ "ID": "BALANCEMOD_AntiExhaustion",
+ "MapLocalVersions": { "0.0": "1.1" },
+ "Default | UpdateKey": "Nexus:637",
+ "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Level Extender": {
+ "ID": "DevinLematty.LevelExtender",
+ "FormerIDs": "Devin Lematty.Level Extender", // changed in 1.3
+ "Default | UpdateKey": "Nexus:1471"
+ },
+
+ "Level Up Notifications": {
+ "ID": "Level Up Notifications",
+ "MapRemoteVersions": { "0.0.1a": "0.0.1" },
+ "Default | UpdateKey": "Nexus:855"
+ },
+
+ "Location and Music Logging": {
+ "ID": "Brandy Lover.LMlog",
+ "Default | UpdateKey": "Nexus:1366"
+ },
+
+ "Longevity": {
+ "ID": "RTGOAT.Longevity",
+ "MapRemoteVersions": { "1.6.8h": "1.6.8" },
+ "Default | UpdateKey": "Nexus:649"
+ },
+
+ "Lookup Anything": {
+ "ID": "Pathoschild.LookupAnything",
+ "FormerIDs": "LookupAnything", // changed in 1.10.1
+ "Default | UpdateKey": "Nexus:541",
+ "~1.18.1 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Love Bubbles": {
+ "ID": "LoveBubbles",
+ "Default | UpdateKey": "Nexus:1318"
+ },
+
+ "Loved Labels": {
+ "ID": "Advize.LovedLabels",
+ "Default | UpdateKey": "Nexus:279"
+ },
+
+ "Luck Skill": {
+ "ID": "spacechase0.LuckSkill",
+ "FormerIDs": "LuckSkill", // changed in 0.1.4
+ "Default | UpdateKey": "Nexus:521"
+ },
+
+ "Magic": {
+ "ID": "spacechase0.Magic",
+ "MapRemoteVersions": { "0.1.2": "0.1.1" } // not updated in manifest
+ },
+
+ "Mail Framework": {
+ "ID": "DIGUS.MailFrameworkMod",
+ "Default | UpdateKey": "Nexus:1536"
+ },
+
+ "MailOrderPigs": {
+ "ID": "jwdred.MailOrderPigs",
+ "Default | UpdateKey": "Nexus:632"
+ },
+
+ "Makeshift Multiplayer": {
+ "ID": "spacechase0.StardewValleyMP",
+ "FormerIDs": "StardewValleyMP", // changed in 0.3
+ "Default | UpdateKey": "Nexus:501"
+ },
+
+ "Map Image Exporter": {
+ "ID": "spacechase0.MapImageExporter",
+ "FormerIDs": "MapImageExporter", // changed in 1.0.2
+ "Default | UpdateKey": "Nexus:1073"
+ },
+
+ "Message Box [API]? (ChatMod)": {
+ "ID": "Kithio:ChatMod",
+ "Default | UpdateKey": "Chucklefish:4296",
+ "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Mining at the Farm": {
+ "ID": "Nishtra.MiningAtTheFarm",
+ "FormerIDs": "MiningAtTheFarm", // changed in <=1.7
+ "Default | UpdateKey": "Nexus:674"
+ },
+
+ "Mining With Explosives": {
+ "ID": "Nishtra.MiningWithExplosives",
+ "FormerIDs": "MiningWithExplosives", // changed in 1.1
+ "Default | UpdateKey": "Nexus:770"
+ },
+
+ "Modder Serialization Utility": {
+ "ID": "SerializerUtils-0-1",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "it's no longer maintained or used."
+ },
+
+ "Monster Level Tip": {
+ "ID": "WhiteMind.MonsterLT",
+ "Default | UpdateKey": "Nexus:1896"
+ },
+
+ "More Animals": {
+ "ID": "Entoarox.MoreAnimals",
+ "FormerIDs": "821ce8f6-e629-41ad-9fde-03b54f68b0b6MOREPETS | Entoarox.MorePets", // changed in 1.3 and 2.0
+ "~2.0.2 | UpdateKey": "Chucklefish:4288" // only enable update checks up to 2.0.2 by request (has its own update-check feature)
+ },
+
+ "More Artifact Spots": {
+ "ID": "451",
+ "Default | UpdateKey": "Nexus:451"
+ },
+
+ "More Map Layers": {
+ "ID": "Platonymous.MoreMapLayers",
+ "Default | UpdateKey": "Nexus:1134" // added in 1.1.1
+ },
+
+ "More Rain": {
+ "ID": "Omegasis.MoreRain",
+ "Default | UpdateKey": "Nexus:441", // added in 1.5.1
+ "~1.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "More Weapons": {
+ "ID": "Joco80.MoreWeapons",
+ "Default | UpdateKey": "Nexus:1168"
+ },
+
+ "Move Faster": {
+ "ID": "shuaiz.MoveFasterMod",
+ "Default | UpdateKey": "Nexus:1351",
+ "1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?)
+ },
+
+ "Multiple Sprites and Portraits On Rotation (File Loading)": {
+ "ID": "FileLoading",
+ "MapLocalVersions": { "1.1": "1.12" },
+ "Default | UpdateKey": "Nexus:1094",
+ "~1.12 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Museum Rearranger": {
+ "ID": "Omegasis.MuseumRearranger",
+ "Default | UpdateKey": "Nexus:428" // added in 1.4.1
+ },
+
+ "Mushroom Level Tip": {
+ "ID": "WhiteMind.MLT",
+ "Default | UpdateKey": "Nexus:1894"
+ },
+
+ "New Machines": {
+ "ID": "F70D4FAB-0AB2-4B78-9F1B-AF2CA2236A59",
+ "Default | UpdateKey": "Chucklefish:3683",
+ "~4.2.1343 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Night Owl": {
+ "ID": "Omegasis.NightOwl",
+ "MapLocalVersions": { "2.1": "1.3" }, // 1.3 had wrong version in manifest
+ "Default | UpdateKey": "Nexus:433" // added in 1.4.1
+ },
+
+ "No Crows": {
+ "ID": "cat.nocrows",
+ "Default | UpdateKey": "Nexus:1682"
+ },
+
+ "No Kids Ever": {
+ "ID": "Hangy.NoKidsEver",
+ "Default | UpdateKey": "Nexus:1464"
+ },
+
+ "No Debug Mode": {
+ "ID": "NoDebugMode",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0."
+ },
+
+ "No Fence Decay": {
+ "ID": "cat.nofencedecay",
+ "Default | UpdateKey": "Nexus:1180"
+ },
+
+ "No More Pets": {
+ "ID": "Omegasis.NoMorePets",
+ "FormerIDs": "NoMorePets", // changed in 1.4
+ "Default | UpdateKey": "Nexus:506" // added in 1.4.1
+ },
+
+ "No Rumble Horse": {
+ "ID": "Xangria.NoRumbleHorse",
+ "Default | UpdateKey": "Nexus:1779"
+ },
+
+ "No Soil Decay": {
+ "ID": "289dee03-5f38-4d8e-8ffc-e440198e8610",
+ "Default | UpdateKey": "Nexus:237",
+ "~0.5 | Status": "AssumeBroken" // broke in SDV 1.2 and uses Assembly.GetExecutingAssembly().Location
+ },
+
+ "No Soil Decay Redux": {
+ "ID": "Platonymous.NoSoilDecayRedux",
+ "Default | UpdateKey": "Nexus:1084" // added in 1.1.9
+ },
+
+ "NPC Map Locations": {
+ "ID": "Bouhm.NPCMapLocations",
+ "FormerIDs": "NPCMapLocationsMod", // changed in 2.0
+ "Default | UpdateKey": "Nexus:239"
+ },
+
+ "Object Time Left": {
+ "ID": "spacechase0.ObjectTimeLeft",
+ "Default | UpdateKey": "Nexus:1315"
+ },
+
+ "OmniFarm": {
+ "ID": "PhthaloBlue.OmniFarm",
+ "FormerIDs": "BlueMod_OmniFarm", // changed in 2.0.2-pathoschild-update
+ "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_OmniFarm"
+ },
+
+ "One Click Shed": {
+ "ID": "BitwiseJonMods.OneClickShedReloader",
+ "Default | UpdateKey": "Nexus:2052"
+ },
+
+ "Out of Season Bonuses (Seasonal Items)": {
+ "ID": "midoriarmstrong.seasonalitems",
+ "Default | UpdateKey": "Nexus:1452"
+ },
+
+ "Part of the Community": {
+ "ID": "SB_PotC",
+ "Default | UpdateKey": "Nexus:923"
+ },
+
+ "PelicanFiber": {
+ "ID": "jwdred.PelicanFiber",
+ "Default | UpdateKey": "Nexus:631"
+ },
+
+ "PelicanTTS": {
+ "ID": "Platonymous.PelicanTTS",
+ "Default | UpdateKey": "Nexus:1079" // added in 1.6.1
+ },
+
+ "Persia the Mermaid - Standalone Custom NPC": {
+ "ID": "63b9f419-7449-42db-ab2e-440b4d05c073",
+ "Default | UpdateKey": "Nexus:1419"
+ },
+
+ "Persistent Game Options": {
+ "ID": "Xangria.PersistentGameOptions",
+ "Default | UpdateKey": "Nexus:1778"
+ },
+
+ "Plant on Grass": {
+ "ID": "Demiacle.PlantOnGrass",
+ "Default | UpdateKey": "Nexus:1026"
+ },
+
+ "PyTK - Platonymous Toolkit": {
+ "ID": "Platonymous.Toolkit",
+ "Default | UpdateKey": "Nexus:1726"
+ },
+
+ "Point-and-Plant": {
+ "ID": "jwdred.PointAndPlant",
+ "Default | UpdateKey": "Nexus:572",
+ "MapRemoteVersions": { "1.0.3": "1.0.2" } // manifest not updated
+ },
+
+ "Pony Weight Loss Program": {
+ "ID": "BadNetCode.PonyWeightLossProgram",
+ "Default | UpdateKey": "Nexus:1232"
+ },
+
+ "Portraiture": {
+ "ID": "Platonymous.Portraiture",
+ "Default | UpdateKey": "Nexus:999" // added in 1.3.1
+ },
+
+ "Prairie King Made Easy": {
+ "ID": "Mucchan.PrairieKingMadeEasy",
+ "Default | UpdateKey": "Chucklefish:3594",
+ "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Purchasable Recipes": {
+ "ID": "Paracosm.PurchasableRecipes",
+ "Default | UpdateKey": "Nexus:1722"
+ },
+
+ "Quest Delay": {
+ "ID": "BadNetCode.QuestDelay",
+ "Default | UpdateKey": "Nexus:1239"
+ },
+
+ "Recatch Legendary Fish": {
+ "ID": "cantorsdust.RecatchLegendaryFish",
+ "FormerIDs": "b3af8c31-48f0-43cf-8343-3eb08bcfa1f9 | community.RecatchLegendaryFish", // changed in 1.3 and 1.5.1
+ "Default | UpdateKey": "Nexus:172"
+ },
+
+ "Regeneration": {
+ "ID": "HammurabiRegeneration",
+ "Default | UpdateKey": "Chucklefish:4584"
+ },
+
+ "Relationship Bar UI": {
+ "ID": "RelationshipBar",
+ "Default | UpdateKey": "Nexus:1009"
+ },
+
+ "RelationshipsEnhanced": {
+ "ID": "relationshipsenhanced",
+ "Default | UpdateKey": "Chucklefish:4435",
+ "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Relationship Status": {
+ "ID": "relationshipstatus",
+ "MapRemoteVersions": { "1.0.5": "1.0.4" }, // not updated in manifest
+ "Default | UpdateKey": "Nexus:751",
+ "~1.0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Rented Tools": {
+ "ID": "JarvieK.RentedTools",
+ "Default | UpdateKey": "Nexus:1307"
+ },
+
+ "Replanter": {
+ "ID": "jwdred.Replanter",
+ "Default | UpdateKey": "Nexus:589"
+ },
+
+ "ReRegeneration": {
+ "ID": "lrsk_sdvm_rerg.0925160827",
+ "MapLocalVersions": { "1.1.2-release": "1.1.2" },
+ "Default | UpdateKey": "Chucklefish:4465"
+ },
+
+ "Reseed": {
+ "ID": "Roc.Reseed",
+ "Default | UpdateKey": "Nexus:887"
+ },
+
+ "Reusable Wallpapers and Floors (Wallpaper Retain)": {
+ "ID": "dae1b553-2e39-43e7-8400-c7c5c836134b",
+ "Default | UpdateKey": "Nexus:356",
+ "~1.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Ring of Fire": {
+ "ID": "Platonymous.RingOfFire",
+ "Default | UpdateKey": "Nexus:1166" // added in 1.0.1
+ },
+
+ "Rope Bridge": {
+ "ID": "RopeBridge",
+ "Default | UpdateKey": "Nexus:824"
+ },
+
+ "Rotate Toolbar": {
+ "ID": "Pathoschild.RotateToolbar",
+ "Default | UpdateKey": "Nexus:1100",
+ "~1.2.1 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Rush Orders": {
+ "ID": "spacechase0.RushOrders",
+ "FormerIDs": "RushOrders", // changed in 1.1
+ "Default | UpdateKey": "Nexus:605"
+ },
+
+ "Save Anywhere": {
+ "ID": "Omegasis.SaveAnywhere",
+ "Default | UpdateKey": "Nexus:444", // added in 2.6.1
+ "MapRemoteVersions": { "2.6.2": "2.6.1" } // not updated in manifest
+ },
+
+ "Save Backup": {
+ "ID": "Omegasis.SaveBackup",
+ "Default | UpdateKey": "Nexus:435", // added in 1.3.1
+ "~1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Scroll to Blank": {
+ "ID": "caraxian.scroll.to.blank",
+ "Default | UpdateKey": "Chucklefish:4405"
+ },
+
+ "Scythe Harvesting": {
+ "ID": "mmanlapat.ScytheHarvesting",
+ "FormerIDs": "ScytheHarvesting", // changed in 1.6
+ "Default | UpdateKey": "Nexus:1106"
+ },
+
+ "SDV Twitch": {
+ "ID": "MTD.SDVTwitch",
+ "Default | UpdateKey": "Nexus:1760"
+ },
+
+ "Seasonal Immersion": {
+ "ID": "Entoarox.SeasonalImmersion",
+ "FormerIDs": "EntoaroxSeasonalHouse | EntoaroxSeasonalBuildings | EntoaroxSeasonalImmersion", // changed in 1.1, 1.6 or earlier, and 1.7
+ "~1.11 | UpdateKey": "Chucklefish:4262" // only enable update checks up to 1.11 by request (has its own update-check feature)
+ },
+
+ "Seed Bag": {
+ "ID": "Platonymous.SeedBag",
+ "Default | UpdateKey": "Nexus:1133" // added in 1.1.2
+ },
+
+ "Seed Catalogue": {
+ "ID": "spacechase0.SeedCatalogue",
+ "Default | UpdateKey": "Nexus:1640"
+ },
+
+ "Self Service": {
+ "ID": "JarvieK.SelfService",
+ "MapRemoteVersions": { "0.2.1": "0.2" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:1304"
+ },
+
+ "Send Items": {
+ "ID": "Denifia.SendItems",
+ "Default | UpdateKey": "Nexus:1087" // added in 1.0.3 (2017-10-04)
+ },
+
+ "Shed Notifications (BuildingsNotifications)": {
+ "ID": "TheCroak.BuildingsNotifications",
+ "Default | UpdateKey": "Nexus:620",
+ "~0.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Shenandoah Project": {
+ "ID": "Nishtra.ShenandoahProject",
+ "FormerIDs": "Shenandoah Project", // changed in 1.2
+ "MapRemoteVersions": { "1.1.1": "1.1" }, // not updated in manifest
+ "Default | UpdateKey": "Nexus:756"
+ },
+
+ "Ship Anywhere": {
+ "ID": "spacechase0.ShipAnywhere",
+ "Default | UpdateKey": "Nexus:1379"
+ },
+
+ "Shipment Tracker": {
+ "ID": "7e474181-e1a0-40f9-9c11-d08a3dcefaf3",
+ "Default | UpdateKey": "Nexus:321"
+ },
+
+ "Shop Expander": {
+ "ID": "Entoarox.ShopExpander",
+ "FormerIDs": "EntoaroxShopExpander", // changed in 1.5 and 1.5.2; disambiguate from Faster Paths
+ "MapRemoteVersions": { "1.6.0b": "1.6.0" },
+ "~1.6 | UpdateKey": "Chucklefish:4381" // only enable update checks up to 1.6 by request (has its own update-check feature)
+ },
+
+ "Showcase Mod": {
+ "ID": "Igorious.Showcase",
+ "MapLocalVersions": { "0.9-500": "0.9" },
+ "Default | UpdateKey": "Chucklefish:4487",
+ "~0.9 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Shroom Spotter": {
+ "ID": "TehPers.ShroomSpotter",
+ "Default | UpdateKey": "Nexus:908"
+ },
+
+ "Simple Crop Label": {
+ "ID": "SimpleCropLabel",
+ "Default | UpdateKey": "Nexus:314"
+ },
+
+ "Simple Sound Manager": {
+ "ID": "Omegasis.SimpleSoundManager",
+ "Default | UpdateKey": "Nexus:1410" // added in 1.0.1
+ },
+
+ "Simple Sprinklers": {
+ "ID": "tZed.SimpleSprinkler",
+ "Default | UpdateKey": "Nexus:76"
+ },
+
+ "Siv's Marriage Mod": {
+ "ID": "6266959802", // official version
+ "FormerIDs": "Siv.MarriageMod | medoli900.Siv's Marriage Mod", // 1.2.3-unofficial versions
+ "MapLocalVersions": { "0.0": "1.4" },
+ "Default | UpdateKey": "Nexus:366"
+ },
+
+ "Skill Prestige": {
+ "ID": "alphablackwolf.skillPrestige",
+ "FormerIDs": "6b843e60-c8fc-4a25-a67b-4a38ac8dcf9b", // changed circa 1.2.3
+ "Default | UpdateKey": "Nexus:569"
+ },
+
+ "Skill Prestige: Cooking Adapter": {
+ "ID": "Alphablackwolf.CookingSkillPrestigeAdapter",
+ "FormerIDs": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63", // changed circa 1.1
+ "MapRemoteVersions": { "1.2.3": "1.1" }, // manifest not updated
+ "Default | UpdateKey": "Nexus:569"
+ },
+
+ "Skip Intro": {
+ "ID": "Pathoschild.SkipIntro",
+ "FormerIDs": "SkipIntro", // changed in 1.4
+ "Default | UpdateKey": "Nexus:533",
+ "~1.7.2 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Skull Cavern Elevator": {
+ "ID": "SkullCavernElevator",
+ "Default | UpdateKey": "Nexus:963"
+ },
+
+ "Skull Cave Saver": {
+ "ID": "cantorsdust.SkullCaveSaver",
+ "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2
+ "Default | UpdateKey": "Nexus:175",
+ "1.3-beta | Status": "AssumeBroken" // doesn't work in multiplayer, no longer maintained
+ },
+
+ "Sleepy Eye": {
+ "ID": "spacechase0.SleepyEye",
+ "Default | UpdateKey": "Nexus:1152"
+ },
+
+ "Slower Fence Decay": {
+ "ID": "Speeder.SlowerFenceDecay",
+ "FormerIDs": "SPDSlowFenceDecay", // changed in 0.5.2-pathoschild-update
+ "Default | UpdateKey": "Nexus:252",
+ "~0.5.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Smart Mod": {
+ "ID": "KuroBear.SmartMod",
+ "~2.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Solar Eclipse Event": {
+ "ID": "KoihimeNakamura.SolarEclipseEvent",
+ "Default | UpdateKey": "Nexus:897",
+ "MapLocalVersions": { "1.3.1-20180131": "1.3.1" }
+ },
+
+ "SpaceCore": {
+ "ID": "spacechase0.SpaceCore",
+ "Default | UpdateKey": "Nexus:1348"
+ },
+
+ "Speedster": {
+ "ID": "Platonymous.Speedster",
+ "Default | UpdateKey": "Nexus:1102" // added in 1.3.1
+ },
+
+ "Split Screen": {
+ "ID": "Ilyaki.SplitScreen",
+ "~3.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals
+ },
+
+ "Sprinkler Range": {
+ "ID": "cat.sprinklerrange",
+ "Default | UpdateKey": "Nexus:1179"
+ },
+
+ "Sprinkles": {
+ "ID": "Platonymous.Sprinkles",
+ "Default | UpdateKey": "Chucklefish:4592"
+ },
+
+ "Sprint and Dash": {
+ "ID": "SPDSprintAndDash",
+ "Default | UpdateKey": "Nexus:235",
+ "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Sprint and Dash Redux": {
+ "ID": "littleraskol.SprintAndDashRedux",
+ "FormerIDs": "lrsk_sdvm_sndr.0921161059", // changed in 1.3
+ "Default | UpdateKey": "Chucklefish:4201"
+ },
+
+ "StackSplitX": {
+ "ID": "tstaples.StackSplitX",
+ "Default | UpdateKey": "Nexus:798"
+ },
+
+ "Stardew Config Menu": {
+ "ID": "Juice805.StardewConfigMenu",
+ "Default | UpdateKey": "Nexus:1312"
+ },
+
+ "Stardew Content Compatibility Layer (SCCL)": {
+ "ID": "SCCL",
+ "Default | UpdateKey": "Nexus:889",
+ "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Stardew Editor Game Integration": {
+ "ID": "spacechase0.StardewEditor.GameIntegration",
+ "Default | UpdateKey": "Nexus:1298"
+ },
+
+ "Stardew Notification": {
+ "ID": "stardewnotification",
+ "Default | UpdateKey": "GitHub:monopandora/StardewNotification"
+ },
+
+ "Stardew Symphony": {
+ "ID": "Omegasis.StardewSymphony",
+ "Default | UpdateKey": "Nexus:425" // added in 1.4.1
+ },
+
+ "StarDustCore": {
+ "ID": "StarDustCore",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "it was only used by earlier versions of Save Anywhere, and is no longer used or maintained."
+ },
+
+ "Starting Money": {
+ "ID": "mmanlapat.StartingMoney",
+ "FormerIDs": "StartingMoney", // changed in 1.1
+ "Default | UpdateKey": "Nexus:1138"
+ },
+
+ "StashItemsToChest": {
+ "ID": "BlueMod_StashItemsToChest",
+ "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_StashItemsToChest",
+ "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Stephan's Lots of Crops": {
+ "ID": "stephansstardewcrops",
+ "MapRemoteVersions": { "1.41": "1.1" }, // manifest not updated
+ "Default | UpdateKey": "Chucklefish:4314"
+ },
+
+ "Stumps to Hardwood Stumps": {
+ "ID": "StumpsToHardwoodStumps",
+ "Default | UpdateKey": "Nexus:691"
+ },
+
+ "Summit Reborn": {
+ "ID": "KoihimeNakamura.summitreborn",
+ "FormerIDs": "emissaryofinfinity.summitreborn", // changed in 1.0.2
+ "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.3 (runtime errors)
+ },
+
+ "Super Greenhouse Warp Modifier": {
+ "ID": "SuperGreenhouse",
+ "Default | UpdateKey": "Chucklefish:4334",
+ "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
+ },
+
+ "Swim Almost Anywhere / Swim Suit": {
+ "ID": "Platonymous.SwimSuit",
+ "Default | UpdateKey": "Nexus:1215" // added in 0.5.1
+ },
+
+ "Tapper Ready": {
+ "ID": "skunkkk.TapperReady",
+ "Default | UpdateKey": "Nexus:1219"
+ },
+
+ "Teh's Fishing Overhaul": {
+ "ID": "TehPers.FishingOverhaul",
+ "Default | UpdateKey": "Nexus:866"
+ },
+
+ "Teleporter": {
+ "ID": "Teleporter",
+ "Default | UpdateKey": "Chucklefish:4374",
+ "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "The Long Night": {
+ "ID": "Pathoschild.TheLongNight",
+ "Default | UpdateKey": "Nexus:1369",
+ "~1.1.1 | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "Three-heart Dance Partner": {
+ "ID": "ThreeHeartDancePartner",
+ "Default | UpdateKey": "Nexus:500"
+ },
+
+ "TimeFreeze": {
+ "ID": "Omegasis.TimeFreeze",
+ "FormerIDs": "4108e859-333c-4fec-a1a7-d2e18c1019fe", // changed in 1.2
+ "Default | UpdateKey": "Nexus:973" // added in 1.2.1
+ },
+
+ "Time Reminder": {
+ "ID": "KoihimeNakamura.TimeReminder",
+ "MapLocalVersions": { "1.0-20170314": "1.0.2" },
+ "Default | UpdateKey": "Nexus:1000"
+ },
+
+ "TimeSpeed": {
+ "ID": "cantorsdust.TimeSpeed",
+ "FormerIDs": "community.TimeSpeed", // changed in 2.3.3
+ "Default | UpdateKey": "Nexus:169"
+ },
+
+ "To Do List": {
+ "ID": "eleanor.todolist",
+ "Default | UpdateKey": "Nexus:1630"
+ },
+
+ "Tool Charging": {
+ "ID": "mralbobo.ToolCharging",
+ "Default | UpdateKey": "GitHub:mralbobo/stardew-tool-charging"
+ },
+
+ "TractorMod": {
+ "ID": "Pathoschild.TractorMod",
+ "FormerIDs": "BlueMod_TractorMod | PhthaloBlue.TractorMod | community.TractorMod", // changed in 3.2, 4.0 beta, and 4.0
+ "Default | UpdateKey": "Nexus:1401",
+ "~4.5-beta | Status": "AssumeBroken" // broke in SDV 1.3
+ },
+
+ "TrainerMod": {
+ "ID": "SMAPI.TrainerMod",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "replaced by ConsoleCommands, which is added by the SMAPI installer."
+ },
+
+ "Tree Transplant": {
+ "ID": "TreeTransplant",
+ "Default | UpdateKey": "Nexus:1342"
+ },
+
+ "UI Info Suite": {
+ "ID": "Cdaragorn.UiInfoSuite",
+ "Default | UpdateKey": "Nexus:1150"
+ },
+
+ "UiModSuite": {
+ "ID": "Demiacle.UiModSuite",
+ "MapLocalVersions": { "0.5": "1.0" }, // not updated in manifest
+ "Default | UpdateKey": "Nexus:1023",
+ "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Variable Grass": {
+ "ID": "dantheman999.VariableGrass",
+ "Default | UpdateKey": "GitHub:dantheman999301/StardewMods"
+ },
+
+ "Vertical Toolbar": {
+ "ID": "SB_VerticalToolMenu",
+ "Default | UpdateKey": "Nexus:943"
+ },
+
+ "WarpAnimals": {
+ "ID": "Symen.WarpAnimals",
+ "Default | UpdateKey": "Nexus:1400"
+ },
+
+ "What Farm Cave / WhatAMush": {
+ "ID": "WhatAMush",
+ "Default | UpdateKey": "Nexus:1097"
+ },
+
+ "WHats Up": {
+ "ID": "wHatsUp",
+ "Default | UpdateKey": "Nexus:1082"
+ },
+
+ "Winter Grass": {
+ "ID": "cat.wintergrass",
+ "Default | UpdateKey": "Nexus:1601"
+ },
+
+ "Xnb Loader": {
+ "ID": "Entoarox.XnbLoader",
+ "~1.1.10 | UpdateKey": "Chucklefish:4506" // only enable update checks up to 1.1.10 by request (has its own update-check feature)
+ },
+
+ "zDailyIncrease": {
+ "ID": "zdailyincrease",
+ "MapRemoteVersions": { "1.3.5": "1.3.4" }, // not updated in manifest
+ "Default | UpdateKey": "Chucklefish:4247"
+ },
+
+ "Zoom Out Extreme": {
+ "ID": "RockinMods.ZoomMod",
+ "FormerIDs": "ZoomMod", // changed circa 1.2.1
+ "Default | UpdateKey": "Nexus:1326",
+ "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Zoryn's Better RNG": {
+ "ID": "Zoryn.BetterRNG",
+ "FormerIDs": "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
+ "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Zoryn's Calendar Anywhere": {
+ "ID": "Zoryn.CalendarAnywhere",
+ "FormerIDs": "a41c01cd-0437-43eb-944f-78cb5a53002a", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
+ "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Zoryn's Durable Fences": {
+ "ID": "Zoryn.DurableFences",
+ "FormerIDs": "56d3439c-7b9b-497e-9496-0c4890e8a00e", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods"
+ },
+
+ "Zoryn's Health Bars": {
+ "ID": "Zoryn.HealthBars",
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
+ "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Zoryn's Fishing Mod": {
+ "ID": "Zoryn.FishingMod",
+ "FormerIDs": "fa277b1f-265e-47c3-a84f-cd320cc74949", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods"
+ },
+
+ "Zoryn's Junimo Deposit Anywhere": {
+ "ID": "Zoryn.JunimoDepositAnywhere",
+ "FormerIDs": "f93a4fe8-cade-4146-9335-b5f82fbbf7bc", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
+ "~1.7 | Status": "AssumeBroken" // broke in SDV 1.2
+ },
+
+ "Zoryn's Movement Mod": {
+ "ID": "Zoryn.MovementModifier",
+ "FormerIDs": "8a632929-8335-484f-87dd-c29d2ba3215d", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods"
+ },
+
+ "Zoryn's Regen Mod": {
+ "ID": "Zoryn.RegenMod",
+ "FormerIDs": "dfac4383-1b6b-4f33-ae4e-37fc23e5252e", // changed in 1.6
+ "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
+ "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
+ }
+ }
+}
diff --git a/src/SMAPI.sln b/src/SMAPI.sln
index 56898a32..d870c30c 100644
--- a/src/SMAPI.sln
+++ b/src/SMAPI.sln
@@ -6,6 +6,9 @@ MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\StardewModdingAPI.Mods.ConsoleCommands.csproj", "{28480467-1A48-46A7-99F8-236D95225359}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI", "SMAPI\StardewModdingAPI.csproj", "{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}"
+ ProjectSection(ProjectDependencies) = postProject
+ {80AD8528-AA49-4731-B4A6-C691845815A1} = {80AD8528-AA49-4731-B4A6-C691845815A1}
+ EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{86C452BE-D2D8-45B4-B63F-E329EB06CEDA}"
ProjectSection(SolutionItems) = preProject
@@ -17,20 +20,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{86C452BE
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Installer", "SMAPI.Installer\StardewModdingAPI.Installer.csproj", "{443DDF81-6AAF-420A-A610-3459F37E5575}"
ProjectSection(ProjectDependencies) = postProject
+ {E272EB5D-8C57-417E-8E60-C1079D3F53C4} = {E272EB5D-8C57-417E-8E60-C1079D3F53C4}
{28480467-1A48-46A7-99F8-236D95225359} = {28480467-1A48-46A7-99F8-236D95225359}
{F1A573B0-F436-472C-AE29-0B91EA6B9F8F} = {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}
EndProjectSection
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.AssemblyRewriters", "SMAPI.AssemblyRewriters\StardewModdingAPI.AssemblyRewriters.csproj", "{10DB0676-9FC1-4771-A2C8-E2519F091E49}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Tests", "SMAPI.Tests\StardewModdingAPI.Tests.csproj", "{36CCB19E-92EB-48C7-9615-98EEFD45109B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Web", "SMAPI.Web\StardewModdingAPI.Web.csproj", "{A308F679-51A3-4006-92D5-BAEC7EBD01A1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Internal", "Internal", "{82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}"
EndProject
-Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "StardewModdingAPI.Common", "SMAPI.Common\StardewModdingAPI.Common.shproj", "{2AA02FB6-FF03-41CF-A215-2EE60AB4F5DC}"
-EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB}"
ProjectSection(SolutionItems) = preProject
..\docs\CONTRIBUTING.md = ..\docs\CONTRIBUTING.md
@@ -57,126 +57,76 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.ModBuildC
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}"
EndProject
+Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "StardewModdingAPI.Internal", "SMAPI.Internal\StardewModdingAPI.Internal.shproj", "{85208F8D-6FD1-4531-BE05-7142490F59FE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Mods.SaveBackup", "SMAPI.Mods.SaveBackup\StardewModdingAPI.Mods.SaveBackup.csproj", "{E272EB5D-8C57-417E-8E60-C1079D3F53C4}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Toolkit", "StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj", "{EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Toolkit.CoreInterfaces", "StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj", "{D5CFD923-37F1-4BC3-9BE8-E506E202AC28}"
+EndProject
Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution
- SMAPI.Common\StardewModdingAPI.Common.projitems*{2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc}*SharedItemsImports = 13
- SMAPI.Common\StardewModdingAPI.Common.projitems*{443ddf81-6aaf-420a-a610-3459f37e5575}*SharedItemsImports = 4
- SMAPI.Common\StardewModdingAPI.Common.projitems*{ea4f1e80-743f-4a1d-9757-ae66904a196a}*SharedItemsImports = 4
- SMAPI.Common\StardewModdingAPI.Common.projitems*{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}*SharedItemsImports = 4
+ SMAPI.Internal\SMAPI.Internal.projitems*{443ddf81-6aaf-420a-a610-3459f37e5575}*SharedItemsImports = 4
+ SMAPI.Internal\SMAPI.Internal.projitems*{85208f8d-6fd1-4531-be05-7142490f59fe}*SharedItemsImports = 13
+ SMAPI.Internal\SMAPI.Internal.projitems*{ea4f1e80-743f-4a1d-9757-ae66904a196a}*SharedItemsImports = 4
+ SMAPI.Internal\SMAPI.Internal.projitems*{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}*SharedItemsImports = 4
EndGlobalSection
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
- Debug|Mixed Platforms = Debug|Mixed Platforms
- Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
- Release|Mixed Platforms = Release|Mixed Platforms
- Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{28480467-1A48-46A7-99F8-236D95225359}.Debug|Any CPU.ActiveCfg = Debug|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Debug|Mixed Platforms.ActiveCfg = Debug|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Debug|Mixed Platforms.Build.0 = Debug|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Debug|x86.ActiveCfg = Debug|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Debug|x86.Build.0 = Debug|x86
+ {28480467-1A48-46A7-99F8-236D95225359}.Debug|Any CPU.Build.0 = Debug|x86
{28480467-1A48-46A7-99F8-236D95225359}.Release|Any CPU.ActiveCfg = Release|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Release|Mixed Platforms.Build.0 = Release|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Release|x86.ActiveCfg = Release|x86
- {28480467-1A48-46A7-99F8-236D95225359}.Release|x86.Build.0 = Release|x86
+ {28480467-1A48-46A7-99F8-236D95225359}.Release|Any CPU.Build.0 = Release|x86
{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Any CPU.ActiveCfg = Debug|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Mixed Platforms.ActiveCfg = Debug|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Mixed Platforms.Build.0 = Debug|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|x86.ActiveCfg = Debug|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|x86.Build.0 = Debug|x86
+ {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Any CPU.Build.0 = Debug|x86
{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Any CPU.ActiveCfg = Release|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Mixed Platforms.Build.0 = Release|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|x86.ActiveCfg = Release|x86
- {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|x86.Build.0 = Release|x86
+ {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Any CPU.Build.0 = Release|x86
{443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Any CPU.ActiveCfg = Debug|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Mixed Platforms.ActiveCfg = Debug|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Mixed Platforms.Build.0 = Debug|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|x86.ActiveCfg = Debug|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|x86.Build.0 = Debug|x86
+ {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Any CPU.Build.0 = Debug|x86
{443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Any CPU.ActiveCfg = Release|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Mixed Platforms.Build.0 = Release|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|x86.ActiveCfg = Release|x86
- {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|x86.Build.0 = Release|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Any CPU.ActiveCfg = Debug|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Mixed Platforms.ActiveCfg = Debug|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Mixed Platforms.Build.0 = Debug|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|x86.ActiveCfg = Debug|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|x86.Build.0 = Debug|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Any CPU.ActiveCfg = Release|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Mixed Platforms.Build.0 = Release|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|x86.ActiveCfg = Release|x86
- {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|x86.Build.0 = Release|x86
+ {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Any CPU.Build.0 = Release|x86
{36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Any CPU.ActiveCfg = Debug|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Mixed Platforms.ActiveCfg = Debug|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Mixed Platforms.Build.0 = Debug|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|x86.ActiveCfg = Debug|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|x86.Build.0 = Debug|x86
+ {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Any CPU.Build.0 = Debug|x86
{36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Any CPU.ActiveCfg = Release|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.Build.0 = Release|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.ActiveCfg = Release|x86
- {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.Build.0 = Release|x86
+ {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Any CPU.Build.0 = Release|x86
{A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|x86.ActiveCfg = Debug|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|x86.Build.0 = Debug|Any CPU
{A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Any CPU.Build.0 = Release|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Mixed Platforms.Build.0 = Release|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|x86.ActiveCfg = Release|Any CPU
- {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|x86.Build.0 = Release|Any CPU
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Debug|Any CPU.ActiveCfg = Debug|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Debug|Mixed Platforms.ActiveCfg = Debug|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Debug|Mixed Platforms.Build.0 = Debug|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Debug|x86.ActiveCfg = Debug|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Debug|x86.Build.0 = Debug|x86
+ {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Debug|Any CPU.Build.0 = Debug|x86
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|Any CPU.ActiveCfg = Release|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|Mixed Platforms.Build.0 = Release|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|x86.ActiveCfg = Release|x86
- {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|x86.Build.0 = Release|x86
+ {EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|Any CPU.Build.0 = Release|x86
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|x86.ActiveCfg = Debug|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|x86.Build.0 = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.Build.0 = Release|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Mixed Platforms.Build.0 = Release|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|x86.ActiveCfg = Release|Any CPU
- {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|x86.Build.0 = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|x86.ActiveCfg = Debug|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|x86.Build.0 = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.Build.0 = Release|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Mixed Platforms.Build.0 = Release|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|x86.ActiveCfg = Release|Any CPU
- {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|x86.Build.0 = Release|Any CPU
+ {E272EB5D-8C57-417E-8E60-C1079D3F53C4}.Debug|Any CPU.ActiveCfg = Debug|x86
+ {E272EB5D-8C57-417E-8E60-C1079D3F53C4}.Debug|Any CPU.Build.0 = Debug|x86
+ {E272EB5D-8C57-417E-8E60-C1079D3F53C4}.Release|Any CPU.ActiveCfg = Release|x86
+ {E272EB5D-8C57-417E-8E60-C1079D3F53C4}.Release|Any CPU.Build.0 = Release|x86
+ {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
- {10DB0676-9FC1-4771-A2C8-E2519F091E49} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
{36CCB19E-92EB-48C7-9615-98EEFD45109B} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
- {2AA02FB6-FF03-41CF-A215-2EE60AB4F5DC} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA}
{09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA}
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 1e35c030..a6cddbe4 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -1,10 +1,10 @@
using System;
-using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using StardewModdingAPI.Framework;
using StardewModdingAPI.Framework.ModLoading;
+using StardewModdingAPI.Internal;
using StardewValley;
namespace StardewModdingAPI
@@ -21,14 +21,6 @@ namespace StardewModdingAPI
/// <summary>Whether the directory containing the current save's data exists on disk.</summary>
private static bool SavePathReady => Context.IsSaveLoaded && Directory.Exists(Constants.RawSavePath);
- /// <summary>Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID). This doesn't affect update checks, which defer to the remote web API.</summary>
- private static readonly IDictionary<string, string> VendorModUrls = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
- {
- ["Chucklefish"] = "https://community.playstarbound.com/resources/{0}",
- ["GitHub"] = "https://github.com/{0}/releases",
- ["Nexus"] = "https://www.nexusmods.com/stardewvalley/mods/{0}"
- };
-
/*********
** Accessors
@@ -37,28 +29,16 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } =
-#if STARDEW_VALLEY_1_3
- new SemanticVersion($"2.6-alpha.{DateTime.UtcNow:yyyyMMddHHmm}");
-#else
- new SemanticVersion("2.5.5");
-#endif
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.6.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MinimumGameVersion { get; } =
-#if STARDEW_VALLEY_1_3
- new GameVersion("1.3.0.4");
-#else
- new SemanticVersion("1.2.30");
-#endif
+ public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.27");
/// <summary>The maximum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MaximumGameVersion { get; } =
-#if STARDEW_VALLEY_1_3
- null;
-#else
- new SemanticVersion("1.2.33");
-#endif
+ public static ISemanticVersion MaximumGameVersion { get; } = null;
+
+ /// <summary>The target game platform.</summary>
+ public static GamePlatform TargetPlatform => (GamePlatform)Constants.Platform;
/// <summary>The path to the game folder.</summary>
public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
@@ -87,8 +67,14 @@ namespace StardewModdingAPI
/// <summary>The file path for the SMAPI configuration file.</summary>
internal static string ApiConfigPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.config.json");
- /// <summary>The file path to the log where the latest output should be saved.</summary>
- internal static string DefaultLogPath => Path.Combine(Constants.LogDir, "SMAPI-latest.txt");
+ /// <summary>The file path for the SMAPI metadata file.</summary>
+ internal static string ApiMetadataPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.metadata.json");
+
+ /// <summary>The filename prefix for SMAPI log files.</summary>
+ internal static string LogNamePrefix { get; } = "SMAPI-latest";
+
+ /// <summary>The filename extension for SMAPI log files.</summary>
+ internal static string LogNameExtension { get; } = "txt";
/// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary>
internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt");
@@ -96,6 +82,9 @@ namespace StardewModdingAPI
/// <summary>The file path which stores a fatal crash message for the next run.</summary>
internal static string FatalCrashMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.crash.marker");
+ /// <summary>The file path which stores the detected update version for the next run.</summary>
+ internal static string UpdateMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.update.marker");
+
/// <summary>The full path to the folder containing mods.</summary>
internal static string ModPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods");
@@ -103,12 +92,10 @@ namespace StardewModdingAPI
internal static ISemanticVersion GameVersion { get; } = new GameVersion(Constants.GetGameVersion());
/// <summary>The target game platform.</summary>
- internal static Platform TargetPlatform { get; } =
-#if SMAPI_FOR_WINDOWS
- Platform.Windows;
-#else
- Platform.Mono;
-#endif
+ internal static Platform Platform { get; } = EnvironmentUtility.DetectPlatform();
+
+ /// <summary>The game's assembly name.</summary>
+ internal static string GameAssemblyName => Constants.Platform == Platform.Windows ? "Stardew Valley" : "StardewValley";
/*********
@@ -123,17 +110,20 @@ namespace StardewModdingAPI
Assembly[] targetAssemblies;
switch (targetPlatform)
{
- case Platform.Mono:
+ case Platform.Linux:
+ case Platform.Mac:
removeAssemblyReferences = new[]
{
+ "Netcode",
"Stardew Valley",
"Microsoft.Xna.Framework",
"Microsoft.Xna.Framework.Game",
- "Microsoft.Xna.Framework.Graphics"
+ "Microsoft.Xna.Framework.Graphics",
+ "Microsoft.Xna.Framework.Xact"
};
targetAssemblies = new[]
{
- typeof(StardewValley.Game1).Assembly,
+ typeof(StardewValley.Game1).Assembly, // note: includes Netcode types on Linux/Mac
typeof(Microsoft.Xna.Framework.Vector2).Assembly
};
break;
@@ -146,6 +136,7 @@ namespace StardewModdingAPI
};
targetAssemblies = new[]
{
+ typeof(Netcode.NetBool).Assembly,
typeof(StardewValley.Game1).Assembly,
typeof(Microsoft.Xna.Framework.Vector2).Assembly,
typeof(Microsoft.Xna.Framework.Game).Assembly,
@@ -160,23 +151,6 @@ namespace StardewModdingAPI
return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences, targetAssemblies);
}
- /// <summary>Get an update URL for an update key (if valid).</summary>
- /// <param name="updateKey">The update key.</param>
- internal static string GetUpdateUrl(string updateKey)
- {
- string[] parts = updateKey.Split(new[] { ':' }, 2);
- if (parts.Length != 2)
- return null;
-
- string vendorKey = parts[0].Trim();
- string modID = parts[1].Trim();
-
- if (Constants.VendorModUrls.TryGetValue(vendorKey, out string urlTemplate))
- return string.Format(urlTemplate, modID);
-
- return null;
- }
-
/*********
** Private methods
@@ -184,12 +158,7 @@ namespace StardewModdingAPI
/// <summary>Get the name of a save directory for the current player.</summary>
private static string GetSaveFolderName()
{
- string prefix =
-#if STARDEW_VALLEY_1_3
- new string(Game1.player.Name.Where(char.IsLetterOrDigit).ToArray());
-#else
- new string(Game1.player.name.Where(char.IsLetterOrDigit).ToArray());
-#endif
+ string prefix = new string(Game1.player.Name.Where(char.IsLetterOrDigit).ToArray());
return $"{prefix}_{Game1.uniqueIDForThisGame}";
}
diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs
index 7ed9b052..3905699e 100644
--- a/src/SMAPI/Context.cs
+++ b/src/SMAPI/Context.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI
public static bool IsWorldReady { get; internal set; }
/// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary>
- public static bool IsPlayerFree => Context.IsWorldReady && Game1.activeClickableMenu == null && !Game1.dialogueUp && !Game1.eventUp;
+ public static bool IsPlayerFree => Context.IsWorldReady && Game1.activeClickableMenu == null && !Game1.dialogueUp && (!Game1.eventUp || Game1.isFestival());
/// <summary>Whether <see cref="IsPlayerFree"/> is true and the player is free to move (e.g. not using a tool).</summary>
public static bool CanPlayerMove => Context.IsPlayerFree && Game1.player.CanMove;
@@ -35,7 +35,7 @@ namespace StardewModdingAPI
** Internal
****/
/// <summary>Whether a player save has been loaded.</summary>
- internal static bool IsSaveLoaded => Game1.hasLoadedGame && !string.IsNullOrEmpty(Game1.player.name);
+ internal static bool IsSaveLoaded => Game1.hasLoadedGame && !(Game1.activeClickableMenu is TitleMenu);
/// <summary>Whether the game is currently writing to the save file.</summary>
internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu || Game1.activeClickableMenu is ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something
diff --git a/src/SMAPI/Events/ControlEvents.cs b/src/SMAPI/Events/ControlEvents.cs
index 973bb245..a3994d1d 100644
--- a/src/SMAPI/Events/ControlEvents.cs
+++ b/src/SMAPI/Events/ControlEvents.cs
@@ -20,57 +20,57 @@ namespace StardewModdingAPI.Events
/// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary>
public static event EventHandler<EventArgsKeyboardStateChanged> KeyboardChanged
{
- add => ControlEvents.EventManager.Control_KeyboardChanged.Add(value);
- remove => ControlEvents.EventManager.Control_KeyboardChanged.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_KeyboardChanged.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_KeyboardChanged.Remove(value);
}
- /// <summary>Raised when the player presses a keyboard key.</summary>
+ /// <summary>Raised after the player presses a keyboard key.</summary>
public static event EventHandler<EventArgsKeyPressed> KeyPressed
{
- add => ControlEvents.EventManager.Control_KeyPressed.Add(value);
- remove => ControlEvents.EventManager.Control_KeyPressed.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_KeyPressed.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_KeyPressed.Remove(value);
}
- /// <summary>Raised when the player releases a keyboard key.</summary>
+ /// <summary>Raised after the player releases a keyboard key.</summary>
public static event EventHandler<EventArgsKeyPressed> KeyReleased
{
- add => ControlEvents.EventManager.Control_KeyReleased.Add(value);
- remove => ControlEvents.EventManager.Control_KeyReleased.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_KeyReleased.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_KeyReleased.Remove(value);
}
/// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary>
public static event EventHandler<EventArgsMouseStateChanged> MouseChanged
{
- add => ControlEvents.EventManager.Control_MouseChanged.Add(value);
- remove => ControlEvents.EventManager.Control_MouseChanged.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_MouseChanged.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_MouseChanged.Remove(value);
}
/// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary>
public static event EventHandler<EventArgsControllerButtonPressed> ControllerButtonPressed
{
- add => ControlEvents.EventManager.Control_ControllerButtonPressed.Add(value);
- remove => ControlEvents.EventManager.Control_ControllerButtonPressed.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_ControllerButtonPressed.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_ControllerButtonPressed.Remove(value);
}
/// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary>
public static event EventHandler<EventArgsControllerButtonReleased> ControllerButtonReleased
{
- add => ControlEvents.EventManager.Control_ControllerButtonReleased.Add(value);
- remove => ControlEvents.EventManager.Control_ControllerButtonReleased.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_ControllerButtonReleased.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_ControllerButtonReleased.Remove(value);
}
/// <summary>The player pressed a controller trigger button.</summary>
public static event EventHandler<EventArgsControllerTriggerPressed> ControllerTriggerPressed
{
- add => ControlEvents.EventManager.Control_ControllerTriggerPressed.Add(value);
- remove => ControlEvents.EventManager.Control_ControllerTriggerPressed.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_ControllerTriggerPressed.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_ControllerTriggerPressed.Remove(value);
}
/// <summary>The player released a controller trigger button.</summary>
public static event EventHandler<EventArgsControllerTriggerReleased> ControllerTriggerReleased
{
- add => ControlEvents.EventManager.Control_ControllerTriggerReleased.Add(value);
- remove => ControlEvents.EventManager.Control_ControllerTriggerReleased.Remove(value);
+ add => ControlEvents.EventManager.Legacy_Control_ControllerTriggerReleased.Add(value);
+ remove => ControlEvents.EventManager.Legacy_Control_ControllerTriggerReleased.Remove(value);
}
diff --git a/src/SMAPI/Events/EventArgsGameLocationsChanged.cs b/src/SMAPI/Events/EventArgsGameLocationsChanged.cs
deleted file mode 100644
index 78ba38fa..00000000
--- a/src/SMAPI/Events/EventArgsGameLocationsChanged.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using System;
-using System.Collections.Generic;
-using StardewValley;
-
-namespace StardewModdingAPI.Events
-{
- /// <summary>Event arguments for a <see cref="LocationEvents.LocationsChanged"/> event.</summary>
- public class EventArgsGameLocationsChanged : EventArgs
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The current list of game locations.</summary>
- public IList<GameLocation> NewLocations { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="newLocations">The current list of game locations.</param>
- public EventArgsGameLocationsChanged(IList<GameLocation> newLocations)
- {
- this.NewLocations = newLocations;
- }
- }
-}
diff --git a/src/SMAPI/Events/EventArgsInput.cs b/src/SMAPI/Events/EventArgsInput.cs
index 75b9b8cd..0cafdba5 100644
--- a/src/SMAPI/Events/EventArgsInput.cs
+++ b/src/SMAPI/Events/EventArgsInput.cs
@@ -1,8 +1,5 @@
using System;
-using System.Linq;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using StardewValley;
+using System.Collections.Generic;
namespace StardewModdingAPI.Events
{
@@ -10,6 +7,13 @@ namespace StardewModdingAPI.Events
public class EventArgsInput : EventArgs
{
/*********
+ ** Properties
+ *********/
+ /// <summary>The buttons to suppress.</summary>
+ private readonly HashSet<SButton> SuppressButtons;
+
+
+ /*********
** Accessors
*********/
/// <summary>The button on the controller, keyboard, or mouse.</summary>
@@ -18,20 +22,14 @@ namespace StardewModdingAPI.Events
/// <summary>The current cursor position.</summary>
public ICursorPosition Cursor { get; }
-#if !STARDEW_VALLEY_1_3
- /// <summary>Whether the input is considered a 'click' by the game for enabling action.</summary>
- [Obsolete("Use " + nameof(EventArgsInput.IsActionButton) + " or " + nameof(EventArgsInput.IsUseToolButton) + " instead")] // deprecated in SMAPI 2.1
- public bool IsClick => this.IsActionButton;
-#endif
-
/// <summary>Whether the input should trigger actions on the affected tile.</summary>
- public bool IsActionButton { get; }
+ public bool IsActionButton => this.Button.IsActionButton();
/// <summary>Whether the input should use tools on the affected tile.</summary>
- public bool IsUseToolButton { get; }
+ public bool IsUseToolButton => this.Button.IsUseToolButton();
/// <summary>Whether a mod has indicated the key was already handled.</summary>
- public bool IsSuppressed { get; private set; }
+ public bool IsSuppressed => this.SuppressButtons.Contains(this.Button);
/*********
@@ -40,17 +38,15 @@ namespace StardewModdingAPI.Events
/// <summary>Construct an instance.</summary>
/// <param name="button">The button on the controller, keyboard, or mouse.</param>
/// <param name="cursor">The cursor position.</param>
- /// <param name="isActionButton">Whether the input should trigger actions on the affected tile.</param>
- /// <param name="isUseToolButton">Whether the input should use tools on the affected tile.</param>
- public EventArgsInput(SButton button, ICursorPosition cursor, bool isActionButton, bool isUseToolButton)
+ /// <param name="suppressButtons">The buttons to suppress.</param>
+ public EventArgsInput(SButton button, ICursorPosition cursor, HashSet<SButton> suppressButtons)
{
this.Button = button;
this.Cursor = cursor;
- this.IsActionButton = isActionButton;
- this.IsUseToolButton = isUseToolButton;
+ this.SuppressButtons = suppressButtons;
}
- /// <summary>Prevent the game from handling the vurrent button press. This doesn't prevent other mods from receiving the event.</summary>
+ /// <summary>Prevent the game from handling the current button press. This doesn't prevent other mods from receiving the event.</summary>
public void SuppressButton()
{
this.SuppressButton(this.Button);
@@ -60,96 +56,7 @@ namespace StardewModdingAPI.Events
/// <param name="button">The button to suppress.</param>
public void SuppressButton(SButton button)
{
- if (button == this.Button)
- this.IsSuppressed = true;
-
- // keyboard
- if (button.TryGetKeyboard(out Keys key))
- Game1.oldKBState = new KeyboardState(Game1.oldKBState.GetPressedKeys().Union(new[] { key }).ToArray());
-
- // controller
- else if (button.TryGetController(out Buttons controllerButton))
- {
- var newState = GamePad.GetState(PlayerIndex.One);
- var thumbsticks = Game1.oldPadState.ThumbSticks;
- var triggers = Game1.oldPadState.Triggers;
- var buttons = Game1.oldPadState.Buttons;
- var dpad = Game1.oldPadState.DPad;
-
- switch (controllerButton)
- {
- // d-pad
- case Buttons.DPadDown:
- dpad = new GamePadDPad(dpad.Up, newState.DPad.Down, dpad.Left, dpad.Right);
- break;
- case Buttons.DPadLeft:
- dpad = new GamePadDPad(dpad.Up, dpad.Down, newState.DPad.Left, dpad.Right);
- break;
- case Buttons.DPadRight:
- dpad = new GamePadDPad(dpad.Up, dpad.Down, dpad.Left, newState.DPad.Right);
- break;
- case Buttons.DPadUp:
- dpad = new GamePadDPad(newState.DPad.Up, dpad.Down, dpad.Left, dpad.Right);
- break;
-
- // trigger
- case Buttons.LeftTrigger:
- triggers = new GamePadTriggers(newState.Triggers.Left, triggers.Right);
- break;
- case Buttons.RightTrigger:
- triggers = new GamePadTriggers(triggers.Left, newState.Triggers.Right);
- break;
-
- // thumbstick
- case Buttons.LeftThumbstickDown:
- case Buttons.LeftThumbstickLeft:
- case Buttons.LeftThumbstickRight:
- case Buttons.LeftThumbstickUp:
- thumbsticks = new GamePadThumbSticks(newState.ThumbSticks.Left, thumbsticks.Right);
- break;
- case Buttons.RightThumbstickDown:
- case Buttons.RightThumbstickLeft:
- case Buttons.RightThumbstickRight:
- case Buttons.RightThumbstickUp:
- thumbsticks = new GamePadThumbSticks(newState.ThumbSticks.Right, thumbsticks.Left);
- break;
-
- // buttons
- default:
- var mask =
- (buttons.A == ButtonState.Pressed ? Buttons.A : 0)
- | (buttons.B == ButtonState.Pressed ? Buttons.B : 0)
- | (buttons.Back == ButtonState.Pressed ? Buttons.Back : 0)
- | (buttons.BigButton == ButtonState.Pressed ? Buttons.BigButton : 0)
- | (buttons.LeftShoulder == ButtonState.Pressed ? Buttons.LeftShoulder : 0)
- | (buttons.LeftStick == ButtonState.Pressed ? Buttons.LeftStick : 0)
- | (buttons.RightShoulder == ButtonState.Pressed ? Buttons.RightShoulder : 0)
- | (buttons.RightStick == ButtonState.Pressed ? Buttons.RightStick : 0)
- | (buttons.Start == ButtonState.Pressed ? Buttons.Start : 0)
- | (buttons.X == ButtonState.Pressed ? Buttons.X : 0)
- | (buttons.Y == ButtonState.Pressed ? Buttons.Y : 0);
- mask = mask ^ controllerButton;
- buttons = new GamePadButtons(mask);
- break;
- }
-
- Game1.oldPadState = new GamePadState(thumbsticks, triggers, buttons, dpad);
- }
-
- // mouse
- else if (button == SButton.MouseLeft || button == SButton.MouseMiddle || button == SButton.MouseRight || button == SButton.MouseX1 || button == SButton.MouseX2)
- {
- Game1.oldMouseState = new MouseState(
- x: Game1.oldMouseState.X,
- y: Game1.oldMouseState.Y,
- scrollWheel: Game1.oldMouseState.ScrollWheelValue,
- leftButton: button == SButton.MouseLeft ? ButtonState.Pressed : Game1.oldMouseState.LeftButton,
- middleButton: button == SButton.MouseMiddle ? ButtonState.Pressed : Game1.oldMouseState.MiddleButton,
- rightButton: button == SButton.MouseRight ? ButtonState.Pressed : Game1.oldMouseState.RightButton,
- xButton1: button == SButton.MouseX1 ? ButtonState.Pressed : Game1.oldMouseState.XButton1,
- xButton2: button == SButton.MouseX2 ? ButtonState.Pressed : Game1.oldMouseState.XButton2
- );
- }
+ this.SuppressButtons.Add(button);
}
}
}
diff --git a/src/SMAPI/Events/EventArgsIntChanged.cs b/src/SMAPI/Events/EventArgsIntChanged.cs
index 0c742d12..a018695c 100644
--- a/src/SMAPI/Events/EventArgsIntChanged.cs
+++ b/src/SMAPI/Events/EventArgsIntChanged.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
namespace StardewModdingAPI.Events
{
@@ -14,6 +14,7 @@ namespace StardewModdingAPI.Events
/// <summary>The current value.</summary>
public int NewInt { get; }
+
/*********
** Public methods
*********/
diff --git a/src/SMAPI/Events/EventArgsInventoryChanged.cs b/src/SMAPI/Events/EventArgsInventoryChanged.cs
index b85ae9db..1fdca834 100644
--- a/src/SMAPI/Events/EventArgsInventoryChanged.cs
+++ b/src/SMAPI/Events/EventArgsInventoryChanged.cs
@@ -12,11 +12,7 @@ namespace StardewModdingAPI.Events
** Accessors
*********/
/// <summary>The player's inventory.</summary>
-#if STARDEW_VALLEY_1_3
public IList<Item> Inventory { get; }
-#else
- public List<Item> Inventory { get; }
-#endif
/// <summary>The added items.</summary>
public List<ItemStackChange> Added { get; }
@@ -34,13 +30,7 @@ namespace StardewModdingAPI.Events
/// <summary>Construct an instance.</summary>
/// <param name="inventory">The player's inventory.</param>
/// <param name="changedItems">The inventory changes.</param>
- public EventArgsInventoryChanged(
-#if STARDEW_VALLEY_1_3
- IList<Item> inventory,
-#else
- List<Item> inventory,
-#endif
- List<ItemStackChange> changedItems)
+ public EventArgsInventoryChanged(IList<Item> inventory, List<ItemStackChange> changedItems)
{
this.Inventory = inventory;
this.Added = changedItems.Where(n => n.ChangeType == ChangeType.Added).ToList();
diff --git a/src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs b/src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs
new file mode 100644
index 00000000..e8184ebe
--- /dev/null
+++ b/src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+using StardewValley.Buildings;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="LocationEvents.BuildingsChanged"/> event.</summary>
+ public class EventArgsLocationBuildingsChanged : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The buildings added to the location.</summary>
+ public IEnumerable<Building> Added { get; }
+
+ /// <summary>The buildings removed from the location.</summary>
+ public IEnumerable<Building> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The buildings added to the location.</param>
+ /// <param name="removed">The buildings removed from the location.</param>
+ public EventArgsLocationBuildingsChanged(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs b/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs
index 180e9d78..3bb387d5 100644
--- a/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs
+++ b/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs
@@ -1,43 +1,40 @@
using System;
-using Microsoft.Xna.Framework;
-#if STARDEW_VALLEY_1_3
using System.Collections.Generic;
-using Netcode;
-#else
+using System.Linq;
+using Microsoft.Xna.Framework;
using StardewValley;
-#endif
-using Object = StardewValley.Object;
+using SObject = StardewValley.Object;
namespace StardewModdingAPI.Events
{
- /// <summary>Event arguments for a <see cref="LocationEvents.LocationObjectsChanged"/> event.</summary>
+ /// <summary>Event arguments for a <see cref="LocationEvents.ObjectsChanged"/> event.</summary>
public class EventArgsLocationObjectsChanged : EventArgs
{
/*********
** Accessors
*********/
- /// <summary>The current list of objects in the current location.</summary>
-#if STARDEW_VALLEY_1_3
- public IDictionary<Vector2, NetRef<Object>> NewObjects { get; }
-#else
- public SerializableDictionary<Vector2, Object> NewObjects { get; }
-#endif
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The objects added to the location.</summary>
+ public IEnumerable<KeyValuePair<Vector2, SObject>> Added { get; }
+
+ /// <summary>The objects removed from the location.</summary>
+ public IEnumerable<KeyValuePair<Vector2, SObject>> Removed { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="newObjects">The current list of objects in the current location.</param>
- public EventArgsLocationObjectsChanged(
-#if STARDEW_VALLEY_1_3
- IDictionary<Vector2, NetRef<Object>> newObjects
-#else
- SerializableDictionary<Vector2, Object> newObjects
-#endif
- )
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The objects added to the location.</param>
+ /// <param name="removed">The objects removed from the location.</param>
+ public EventArgsLocationObjectsChanged(GameLocation location, IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed)
{
- this.NewObjects = newObjects;
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
}
}
}
diff --git a/src/SMAPI/Events/EventArgsLocationsChanged.cs b/src/SMAPI/Events/EventArgsLocationsChanged.cs
new file mode 100644
index 00000000..20984f45
--- /dev/null
+++ b/src/SMAPI/Events/EventArgsLocationsChanged.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="LocationEvents.LocationsChanged"/> event.</summary>
+ public class EventArgsLocationsChanged : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The added locations.</summary>
+ public IEnumerable<GameLocation> Added { get; }
+
+ /// <summary>The removed locations.</summary>
+ public IEnumerable<GameLocation> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="added">The added locations.</param>
+ /// <param name="removed">The removed locations.</param>
+ public EventArgsLocationsChanged(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed)
+ {
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/EventArgsCurrentLocationChanged.cs b/src/SMAPI/Events/EventArgsPlayerWarped.cs
index 25d3ebf3..93026aea 100644
--- a/src/SMAPI/Events/EventArgsCurrentLocationChanged.cs
+++ b/src/SMAPI/Events/EventArgsPlayerWarped.cs
@@ -1,19 +1,20 @@
-using System;
+using System;
using StardewValley;
namespace StardewModdingAPI.Events
{
- /// <summary>Event arguments for a <see cref="LocationEvents.CurrentLocationChanged"/> event.</summary>
- public class EventArgsCurrentLocationChanged : EventArgs
+ /// <summary>Event arguments for a <see cref="PlayerEvents.Warped"/> event.</summary>
+ public class EventArgsPlayerWarped : EventArgs
{
/*********
** Accessors
*********/
+ /// <summary>The player's previous location.</summary>
+ public GameLocation PriorLocation { get; }
+
/// <summary>The player's current location.</summary>
public GameLocation NewLocation { get; }
- /// <summary>The player's previous location.</summary>
- public GameLocation PriorLocation { get; }
/*********
@@ -22,7 +23,7 @@ namespace StardewModdingAPI.Events
/// <summary>Construct an instance.</summary>
/// <param name="priorLocation">The player's previous location.</param>
/// <param name="newLocation">The player's current location.</param>
- public EventArgsCurrentLocationChanged(GameLocation priorLocation, GameLocation newLocation)
+ public EventArgsPlayerWarped(GameLocation priorLocation, GameLocation newLocation)
{
this.NewLocation = newLocation;
this.PriorLocation = priorLocation;
diff --git a/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs b/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs
new file mode 100644
index 00000000..6a42e4f9
--- /dev/null
+++ b/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs
@@ -0,0 +1,7 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IGameLoopEvents.Launched"/> event.</summary>
+ public class GameLoopLaunchedEventArgs : EventArgs { }
+}
diff --git a/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs b/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs
new file mode 100644
index 00000000..3ad34b69
--- /dev/null
+++ b/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs
@@ -0,0 +1,36 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IGameLoopEvents.Updated"/> event.</summary>
+ public class GameLoopUpdatedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary>
+ public uint Ticks { get; }
+
+ /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary>
+ public bool IsOneSecond { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param>
+ public GameLoopUpdatedEventArgs(uint ticks)
+ {
+ this.Ticks = ticks;
+ this.IsOneSecond = this.IsMultipleOf(60);
+ }
+
+ /// <summary>Get whether <see cref="Ticks"/> is a multiple of the given <paramref name="number"/>. This is mainly useful if you want to run logic intermittently (e.g. <code>e.IsMultipleOf(30)</code> for every half-second).</summary>
+ /// <param name="number">The factor to check.</param>
+ public bool IsMultipleOf(uint number)
+ {
+ return this.Ticks % number == 0;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs b/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs
new file mode 100644
index 00000000..d6a8b5c2
--- /dev/null
+++ b/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs
@@ -0,0 +1,36 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IGameLoopEvents.Updating"/> event.</summary>
+ public class GameLoopUpdatingEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary>
+ public uint Ticks { get; }
+
+ /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary>
+ public bool IsOneSecond { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param>
+ public GameLoopUpdatingEventArgs(uint ticks)
+ {
+ this.Ticks = ticks;
+ this.IsOneSecond = this.IsMultipleOf(60);
+ }
+
+ /// <summary>Get whether <see cref="Ticks"/> is a multiple of the given <paramref name="number"/>. This is mainly useful if you want to run logic intermittently (e.g. <code>e.IsMultipleOf(30)</code> for every half-second).</summary>
+ /// <param name="number">The factor to check.</param>
+ public bool IsMultipleOf(uint number)
+ {
+ return this.Ticks % number == 0;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/IGameLoopEvents.cs b/src/SMAPI/Events/IGameLoopEvents.cs
new file mode 100644
index 00000000..a56b3de3
--- /dev/null
+++ b/src/SMAPI/Events/IGameLoopEvents.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IInputEvents"/> if possible.</summary>
+ public interface IGameLoopEvents
+ {
+ /// <summary>Raised after the game is launched, right before the first update tick. This happens once per game session (unrelated to loading saves). All mods are loaded and initialised at this point, so this is a good time to set up mod integrations.</summary>
+ event EventHandler<GameLoopLaunchedEventArgs> Launched;
+
+ /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary>
+ event EventHandler<GameLoopUpdatingEventArgs> Updating;
+
+ /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary>
+ event EventHandler<GameLoopUpdatedEventArgs> Updated;
+ }
+}
diff --git a/src/SMAPI/Events/IInputEvents.cs b/src/SMAPI/Events/IInputEvents.cs
new file mode 100644
index 00000000..8e2ef406
--- /dev/null
+++ b/src/SMAPI/Events/IInputEvents.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
+ public interface IInputEvents
+ {
+ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
+ event EventHandler<InputButtonPressedEventArgs> ButtonPressed;
+
+ /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary>
+ event EventHandler<InputButtonReleasedEventArgs> ButtonReleased;
+
+ /// <summary>Raised after the player moves the in-game cursor.</summary>
+ event EventHandler<InputCursorMovedEventArgs> CursorMoved;
+
+ /// <summary>Raised after the player scrolls the mouse wheel.</summary>
+ event EventHandler<InputMouseWheelScrolledEventArgs> MouseWheelScrolled;
+ }
+}
diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs
new file mode 100644
index 00000000..cf2f8cb8
--- /dev/null
+++ b/src/SMAPI/Events/IModEvents.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Manages access to events raised by SMAPI.</summary>
+ public interface IModEvents
+ {
+ /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="Input"/> if possible.</summary>
+ IGameLoopEvents GameLoop { get; }
+
+ /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
+ IInputEvents Input { get; }
+
+ /// <summary>Events raised when something changes in the world.</summary>
+ IWorldEvents World { get; }
+ }
+}
diff --git a/src/SMAPI/Events/IWorldEvents.cs b/src/SMAPI/Events/IWorldEvents.cs
new file mode 100644
index 00000000..d4efb53b
--- /dev/null
+++ b/src/SMAPI/Events/IWorldEvents.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Events raised when something changes in the world.</summary>
+ public interface IWorldEvents
+ {
+ /// <summary>Raised after a game location is added or removed.</summary>
+ event EventHandler<WorldLocationListChangedEventArgs> LocationListChanged;
+
+ /// <summary>Raised after buildings are added or removed in a location.</summary>
+ event EventHandler<WorldBuildingListChangedEventArgs> BuildingListChanged;
+
+ /// <summary>Raised after debris are added or removed in a location.</summary>
+ event EventHandler<WorldDebrisListChangedEventArgs> DebrisListChanged;
+
+ /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary>
+ event EventHandler<WorldLargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged;
+
+ /// <summary>Raised after NPCs are added or removed in a location.</summary>
+ event EventHandler<WorldNpcListChangedEventArgs> NpcListChanged;
+
+ /// <summary>Raised after objects are added or removed in a location.</summary>
+ event EventHandler<WorldObjectListChangedEventArgs> ObjectListChanged;
+
+ /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
+ event EventHandler<WorldTerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
+ }
+}
diff --git a/src/SMAPI/Events/InputButtonPressedEventArgs.cs b/src/SMAPI/Events/InputButtonPressedEventArgs.cs
new file mode 100644
index 00000000..8c6844dd
--- /dev/null
+++ b/src/SMAPI/Events/InputButtonPressedEventArgs.cs
@@ -0,0 +1,60 @@
+using System;
+using StardewModdingAPI.Framework.Input;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments when a button is pressed.</summary>
+ public class InputButtonPressedEventArgs : EventArgs
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The game's current input state.</summary>
+ private readonly SInputState InputState;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The button on the controller, keyboard, or mouse.</summary>
+ public SButton Button { get; }
+
+ /// <summary>The current cursor position.</summary>
+ public ICursorPosition Cursor { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="button">The button on the controller, keyboard, or mouse.</param>
+ /// <param name="cursor">The cursor position.</param>
+ /// <param name="inputState">The game's current input state.</param>
+ internal InputButtonPressedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState)
+ {
+ this.Button = button;
+ this.Cursor = cursor;
+ this.InputState = inputState;
+ }
+
+ /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ public bool IsSuppressed()
+ {
+ return this.IsSuppressed(this.Button);
+ }
+
+ /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ /// <param name="button">The button to check.</param>
+ public bool IsSuppressed(SButton button)
+ {
+ return this.InputState.SuppressButtons.Contains(button);
+ }
+
+ /// <summary>Get whether a given button was pressed or held.</summary>
+ /// <param name="button">The button to check.</param>
+ public bool IsDown(SButton button)
+ {
+ return this.InputState.IsDown(button);
+ }
+ }
+}
diff --git a/src/SMAPI/Events/InputButtonReleasedEventArgs.cs b/src/SMAPI/Events/InputButtonReleasedEventArgs.cs
new file mode 100644
index 00000000..4b0bc326
--- /dev/null
+++ b/src/SMAPI/Events/InputButtonReleasedEventArgs.cs
@@ -0,0 +1,60 @@
+using System;
+using StardewModdingAPI.Framework.Input;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments when a button is released.</summary>
+ public class InputButtonReleasedEventArgs : EventArgs
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The game's current input state.</summary>
+ private readonly SInputState InputState;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The button on the controller, keyboard, or mouse.</summary>
+ public SButton Button { get; }
+
+ /// <summary>The current cursor position.</summary>
+ public ICursorPosition Cursor { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="button">The button on the controller, keyboard, or mouse.</param>
+ /// <param name="cursor">The cursor position.</param>
+ /// <param name="inputState">The game's current input state.</param>
+ internal InputButtonReleasedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState)
+ {
+ this.Button = button;
+ this.Cursor = cursor;
+ this.InputState = inputState;
+ }
+
+ /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ public bool IsSuppressed()
+ {
+ return this.IsSuppressed(this.Button);
+ }
+
+ /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ /// <param name="button">The button to check.</param>
+ public bool IsSuppressed(SButton button)
+ {
+ return this.InputState.SuppressButtons.Contains(button);
+ }
+
+ /// <summary>Get whether a given button was pressed or held.</summary>
+ /// <param name="button">The button to check.</param>
+ public bool IsDown(SButton button)
+ {
+ return this.InputState.IsDown(button);
+ }
+ }
+}
diff --git a/src/SMAPI/Events/InputCursorMovedEventArgs.cs b/src/SMAPI/Events/InputCursorMovedEventArgs.cs
new file mode 100644
index 00000000..53aac5b3
--- /dev/null
+++ b/src/SMAPI/Events/InputCursorMovedEventArgs.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments when the in-game cursor is moved.</summary>
+ public class InputCursorMovedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The previous cursor position.</summary>
+ public ICursorPosition OldPosition { get; }
+
+ /// <summary>The current cursor position.</summary>
+ public ICursorPosition NewPosition { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="oldPosition">The previous cursor position.</param>
+ /// <param name="newPosition">The new cursor position.</param>
+ public InputCursorMovedEventArgs(ICursorPosition oldPosition, ICursorPosition newPosition)
+ {
+ this.OldPosition = oldPosition;
+ this.NewPosition = newPosition;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/InputEvents.cs b/src/SMAPI/Events/InputEvents.cs
index 84d7ce5d..e62d6ee6 100644
--- a/src/SMAPI/Events/InputEvents.cs
+++ b/src/SMAPI/Events/InputEvents.cs
@@ -19,15 +19,15 @@ namespace StardewModdingAPI.Events
/// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary>
public static event EventHandler<EventArgsInput> ButtonPressed
{
- add => InputEvents.EventManager.Input_ButtonPressed.Add(value);
- remove => InputEvents.EventManager.Input_ButtonPressed.Remove(value);
+ add => InputEvents.EventManager.Legacy_Input_ButtonPressed.Add(value);
+ remove => InputEvents.EventManager.Legacy_Input_ButtonPressed.Remove(value);
}
/// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary>
public static event EventHandler<EventArgsInput> ButtonReleased
{
- add => InputEvents.EventManager.Input_ButtonReleased.Add(value);
- remove => InputEvents.EventManager.Input_ButtonReleased.Remove(value);
+ add => InputEvents.EventManager.Legacy_Input_ButtonReleased.Add(value);
+ remove => InputEvents.EventManager.Legacy_Input_ButtonReleased.Remove(value);
}
diff --git a/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs b/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs
new file mode 100644
index 00000000..9afab9cc
--- /dev/null
+++ b/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs
@@ -0,0 +1,38 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments when the player scrolls the mouse wheel.</summary>
+ public class InputMouseWheelScrolledEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The cursor position.</summary>
+ public ICursorPosition Position { get; }
+
+ /// <summary>The old scroll value.</summary>
+ public int OldValue { get; }
+
+ /// <summary>The new scroll value.</summary>
+ public int NewValue { get; }
+
+ /// <summary>The amount by which the scroll value changed.</summary>
+ public int Delta => this.NewValue - this.OldValue;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="position">The cursor position.</param>
+ /// <param name="oldValue">The old scroll value.</param>
+ /// <param name="newValue">The new scroll value.</param>
+ public InputMouseWheelScrolledEventArgs(ICursorPosition position, int oldValue, int newValue)
+ {
+ this.Position = position;
+ this.OldValue = oldValue;
+ this.NewValue = newValue;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/LocationEvents.cs b/src/SMAPI/Events/LocationEvents.cs
index 81d13e9f..e2108de0 100644
--- a/src/SMAPI/Events/LocationEvents.cs
+++ b/src/SMAPI/Events/LocationEvents.cs
@@ -16,25 +16,25 @@ namespace StardewModdingAPI.Events
/*********
** Events
*********/
- /// <summary>Raised after the player warps to a new location.</summary>
- public static event EventHandler<EventArgsCurrentLocationChanged> CurrentLocationChanged
+ /// <summary>Raised after a game location is added or removed.</summary>
+ public static event EventHandler<EventArgsLocationsChanged> LocationsChanged
{
- add => LocationEvents.EventManager.Location_CurrentLocationChanged.Add(value);
- remove => LocationEvents.EventManager.Location_CurrentLocationChanged.Remove(value);
+ add => LocationEvents.EventManager.Legacy_Location_LocationsChanged.Add(value);
+ remove => LocationEvents.EventManager.Legacy_Location_LocationsChanged.Remove(value);
}
- /// <summary>Raised after a game location is added or removed.</summary>
- public static event EventHandler<EventArgsGameLocationsChanged> LocationsChanged
+ /// <summary>Raised after buildings are added or removed in a location.</summary>
+ public static event EventHandler<EventArgsLocationBuildingsChanged> BuildingsChanged
{
- add => LocationEvents.EventManager.Location_LocationsChanged.Add(value);
- remove => LocationEvents.EventManager.Location_LocationsChanged.Remove(value);
+ add => LocationEvents.EventManager.Legacy_Location_BuildingsChanged.Add(value);
+ remove => LocationEvents.EventManager.Legacy_Location_BuildingsChanged.Remove(value);
}
- /// <summary>Raised after the list of objects in the current location changes (e.g. an object is added or removed).</summary>
- public static event EventHandler<EventArgsLocationObjectsChanged> LocationObjectsChanged
+ /// <summary>Raised after objects are added or removed in a location.</summary>
+ public static event EventHandler<EventArgsLocationObjectsChanged> ObjectsChanged
{
- add => LocationEvents.EventManager.Location_LocationObjectsChanged.Add(value);
- remove => LocationEvents.EventManager.Location_LocationObjectsChanged.Remove(value);
+ add => LocationEvents.EventManager.Legacy_Location_ObjectsChanged.Add(value);
+ remove => LocationEvents.EventManager.Legacy_Location_ObjectsChanged.Remove(value);
}
diff --git a/src/SMAPI/Events/MultiplayerEvents.cs b/src/SMAPI/Events/MultiplayerEvents.cs
new file mode 100644
index 00000000..f96ecba5
--- /dev/null
+++ b/src/SMAPI/Events/MultiplayerEvents.cs
@@ -0,0 +1,58 @@
+using System;
+using StardewModdingAPI.Framework.Events;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Events raised during the multiplayer sync process.</summary>
+ public static class MultiplayerEvents
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The core event manager.</summary>
+ private static EventManager EventManager;
+
+
+ /*********
+ ** Events
+ *********/
+ /// <summary>Raised before the game syncs changes from other players.</summary>
+ public static event EventHandler BeforeMainSync
+ {
+ add => MultiplayerEvents.EventManager.Multiplayer_BeforeMainSync.Add(value);
+ remove => MultiplayerEvents.EventManager.Multiplayer_BeforeMainSync.Remove(value);
+ }
+
+ /// <summary>Raised after the game syncs changes from other players.</summary>
+ public static event EventHandler AfterMainSync
+ {
+ add => MultiplayerEvents.EventManager.Multiplayer_AfterMainSync.Add(value);
+ remove => MultiplayerEvents.EventManager.Multiplayer_AfterMainSync.Remove(value);
+ }
+
+ /// <summary>Raised before the game broadcasts changes to other players.</summary>
+ public static event EventHandler BeforeMainBroadcast
+ {
+ add => MultiplayerEvents.EventManager.Multiplayer_BeforeMainBroadcast.Add(value);
+ remove => MultiplayerEvents.EventManager.Multiplayer_BeforeMainBroadcast.Remove(value);
+ }
+
+ /// <summary>Raised after the game broadcasts changes to other players.</summary>
+ public static event EventHandler AfterMainBroadcast
+ {
+ add => MultiplayerEvents.EventManager.Multiplayer_AfterMainBroadcast.Add(value);
+ remove => MultiplayerEvents.EventManager.Multiplayer_AfterMainBroadcast.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Initialise the events.</summary>
+ /// <param name="eventManager">The core event manager.</param>
+ internal static void Init(EventManager eventManager)
+ {
+ MultiplayerEvents.EventManager = eventManager;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/PlayerEvents.cs b/src/SMAPI/Events/PlayerEvents.cs
index 84a7ff63..6e7050e3 100644
--- a/src/SMAPI/Events/PlayerEvents.cs
+++ b/src/SMAPI/Events/PlayerEvents.cs
@@ -23,13 +23,21 @@ namespace StardewModdingAPI.Events
remove => PlayerEvents.EventManager.Player_InventoryChanged.Remove(value);
}
- /// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary>
+ /// <summary>Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary>
public static event EventHandler<EventArgsLevelUp> LeveledUp
{
add => PlayerEvents.EventManager.Player_LeveledUp.Add(value);
remove => PlayerEvents.EventManager.Player_LeveledUp.Remove(value);
}
+ /// <summary>Raised after the player warps to a new location.</summary>
+ public static event EventHandler<EventArgsPlayerWarped> Warped
+ {
+ add => PlayerEvents.EventManager.Player_Warped.Add(value);
+ remove => PlayerEvents.EventManager.Player_Warped.Remove(value);
+ }
+
+
/*********
** Public methods
diff --git a/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs b/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs
new file mode 100644
index 00000000..e73b9396
--- /dev/null
+++ b/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+using StardewValley.Buildings;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.BuildingListChanged"/> event.</summary>
+ public class WorldBuildingListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The buildings added to the location.</summary>
+ public IEnumerable<Building> Added { get; }
+
+ /// <summary>The buildings removed from the location.</summary>
+ public IEnumerable<Building> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The buildings added to the location.</param>
+ /// <param name="removed">The buildings removed from the location.</param>
+ public WorldBuildingListChangedEventArgs(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs b/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs
new file mode 100644
index 00000000..aad9c24d
--- /dev/null
+++ b/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.DebrisListChanged"/> event.</summary>
+ public class WorldDebrisListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The debris added to the location.</summary>
+ public IEnumerable<Debris> Added { get; }
+
+ /// <summary>The debris removed from the location.</summary>
+ public IEnumerable<Debris> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The debris added to the location.</param>
+ /// <param name="removed">The debris removed from the location.</param>
+ public WorldDebrisListChangedEventArgs(GameLocation location, IEnumerable<Debris> added, IEnumerable<Debris> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs b/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs
new file mode 100644
index 00000000..053a0e41
--- /dev/null
+++ b/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+using StardewValley.TerrainFeatures;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.LargeTerrainFeatureListChanged"/> event.</summary>
+ public class WorldLargeTerrainFeatureListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The large terrain features added to the location.</summary>
+ public IEnumerable<LargeTerrainFeature> Added { get; }
+
+ /// <summary>The large terrain features removed from the location.</summary>
+ public IEnumerable<LargeTerrainFeature> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The large terrain features added to the location.</param>
+ /// <param name="removed">The large terrain features removed from the location.</param>
+ public WorldLargeTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<LargeTerrainFeature> added, IEnumerable<LargeTerrainFeature> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs b/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs
new file mode 100644
index 00000000..8bc26a43
--- /dev/null
+++ b/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.LocationListChanged"/> event.</summary>
+ public class WorldLocationListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The added locations.</summary>
+ public IEnumerable<GameLocation> Added { get; }
+
+ /// <summary>The removed locations.</summary>
+ public IEnumerable<GameLocation> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="added">The added locations.</param>
+ /// <param name="removed">The removed locations.</param>
+ public WorldLocationListChangedEventArgs(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed)
+ {
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs b/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs
new file mode 100644
index 00000000..e251f894
--- /dev/null
+++ b/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewValley;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.NpcListChanged"/> event.</summary>
+ public class WorldNpcListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The NPCs added to the location.</summary>
+ public IEnumerable<NPC> Added { get; }
+
+ /// <summary>The NPCs removed from the location.</summary>
+ public IEnumerable<NPC> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The NPCs added to the location.</param>
+ /// <param name="removed">The NPCs removed from the location.</param>
+ public WorldNpcListChangedEventArgs(GameLocation location, IEnumerable<NPC> added, IEnumerable<NPC> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs b/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs
new file mode 100644
index 00000000..5623a49b
--- /dev/null
+++ b/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using StardewValley;
+using Object = StardewValley.Object;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.ObjectListChanged"/> event.</summary>
+ public class WorldObjectListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The objects added to the location.</summary>
+ public IEnumerable<KeyValuePair<Vector2, Object>> Added { get; }
+
+ /// <summary>The objects removed from the location.</summary>
+ public IEnumerable<KeyValuePair<Vector2, Object>> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The objects added to the location.</param>
+ /// <param name="removed">The objects removed from the location.</param>
+ public WorldObjectListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, Object>> added, IEnumerable<KeyValuePair<Vector2, Object>> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs b/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs
new file mode 100644
index 00000000..cb089811
--- /dev/null
+++ b/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using StardewValley;
+using StardewValley.TerrainFeatures;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.TerrainFeatureListChanged"/> event.</summary>
+ public class WorldTerrainFeatureListChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The location which changed.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The terrain features added to the location.</summary>
+ public IEnumerable<KeyValuePair<Vector2, TerrainFeature>> Added { get; }
+
+ /// <summary>The terrain features removed from the location.</summary>
+ public IEnumerable<KeyValuePair<Vector2, TerrainFeature>> Removed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location which changed.</param>
+ /// <param name="added">The terrain features added to the location.</param>
+ /// <param name="removed">The terrain features removed from the location.</param>
+ public WorldTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> added, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> removed)
+ {
+ this.Location = location;
+ this.Added = added.ToArray();
+ this.Removed = removed.ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs
index 79a23d03..f9651ed9 100644
--- a/src/SMAPI/Framework/CommandManager.cs
+++ b/src/SMAPI/Framework/CommandManager.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Text;
namespace StardewModdingAPI.Framework
{
@@ -72,7 +73,7 @@ namespace StardewModdingAPI.Framework
if (string.IsNullOrWhiteSpace(input))
return false;
- string[] args = input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ string[] args = this.ParseArgs(input);
string name = args[0];
args = args.Skip(1).ToArray();
@@ -103,6 +104,31 @@ namespace StardewModdingAPI.Framework
/*********
** Private methods
*********/
+ /// <summary>Parse a string into command arguments.</summary>
+ /// <param name="input">The string to parse.</param>
+ private string[] ParseArgs(string input)
+ {
+ bool inQuotes = false;
+ IList<string> args = new List<string>();
+ StringBuilder currentArg = new StringBuilder();
+ foreach (char ch in input)
+ {
+ if (ch == '"')
+ inQuotes = !inQuotes;
+ else if (!inQuotes && char.IsWhiteSpace(ch))
+ {
+ args.Add(currentArg.ToString());
+ currentArg.Clear();
+ }
+ else
+ currentArg.Append(ch);
+ }
+
+ args.Add(currentArg.ToString());
+
+ return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray();
+ }
+
/// <summary>Get a normalised command name.</summary>
/// <param name="name">The command name.</param>
private string GetNormalisedName(string name)
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 1eef2afb..5c7b87de 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -4,7 +4,7 @@ using Microsoft.Xna.Framework.Graphics;
namespace StardewModdingAPI.Framework.Content
{
- /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary>
+ /// <summary>Encapsulates access and changes to image content being read from a data file.</summary>
internal class AssetDataForImage : AssetData<Texture2D>, IAssetDataForImage
{
/*********
@@ -29,6 +29,8 @@ namespace StardewModdingAPI.Framework.Content
public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace)
{
// get texture
+ if (source == null)
+ throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture.");
Texture2D target = this.Data;
// get areas
@@ -36,8 +38,6 @@ namespace StardewModdingAPI.Framework.Content
targetArea = targetArea ?? new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
// validate
- if (source == null)
- throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture.");
if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)
throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture.");
if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height)
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index 533da398..a5dfac9d 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -3,9 +3,9 @@ using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
using Microsoft.Xna.Framework;
-using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Reflection;
-using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Internal;
+using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
namespace StardewModdingAPI.Framework.Content
@@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.Content
this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue();
// get key normalisation logic
- if (Constants.TargetPlatform == Platform.Windows)
+ if (Constants.Platform == Platform.Windows)
{
IReflectedMethod method = reflection.GetMethod(typeof(TitleContainer), "GetCleanPath");
this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path);
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
new file mode 100644
index 00000000..d9b2109a
--- /dev/null
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -0,0 +1,315 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.ContentManagers;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Metadata;
+using StardewModdingAPI.Toolkit.Utilities;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>The central logic for creating content managers, invalidating caches, and propagating asset changes.</summary>
+ internal class ContentCoordinator : IDisposable
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>An asset key prefix for assets from SMAPI mod folders.</summary>
+ private readonly string ManagedPrefix = "SMAPI";
+
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>Provides metadata for core game assets.</summary>
+ private readonly CoreAssetPropagator CoreAssets;
+
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
+ /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
+ private readonly IList<IContentManager> ContentManagers = new List<IContentManager>();
+
+ /// <summary>Whether the content coordinator has been disposed.</summary>
+ private bool IsDisposed;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The primary content manager used for most assets.</summary>
+ public GameContentManager MainContentManager { get; private set; }
+
+ /// <summary>The current language as a constant.</summary>
+ public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
+
+ /// <summary>Interceptors which provide the initial versions of matching assets.</summary>
+ public IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>();
+
+ /// <summary>Interceptors which edit matching assets after they're loaded.</summary>
+ public IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>();
+
+ /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
+ public string FullRootDirectory { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="serviceProvider">The service provider to use to locate services.</param>
+ /// <param name="rootDirectory">The root directory to search for content.</param>
+ /// <param name="currentCulture">The current culture for which to localise content.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection)
+ {
+ this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+ this.Reflection = reflection;
+ this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
+ this.ContentManagers.Add(
+ this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing)
+ );
+ this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormaliseAssetName, reflection);
+ }
+
+ /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ public GameContentManager CreateGameContentManager(string name)
+ {
+ GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing);
+ this.ContentManagers.Add(manager);
+ return manager;
+ }
+
+ /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param>
+ public ModContentManager CreateModContentManager(string name, string rootDirectory)
+ {
+ ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing);
+ this.ContentManagers.Add(manager);
+ return manager;
+ }
+
+ /// <summary>Get the current content locale.</summary>
+ public string GetLocale()
+ {
+ return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode);
+ }
+
+ /// <summary>Get whether this asset is mapped to a mod folder.</summary>
+ /// <param name="key">The asset key.</param>
+ public bool IsManagedAssetKey(string key)
+ {
+ return key.StartsWith(this.ManagedPrefix);
+ }
+
+ /// <summary>Parse a managed SMAPI asset key which maps to a mod folder.</summary>
+ /// <param name="key">The asset key.</param>
+ /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
+ /// <param name="relativePath">The relative path within the mod folder.</param>
+ /// <returns>Returns whether the asset was parsed successfully.</returns>
+ public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string relativePath)
+ {
+ contentManagerID = null;
+ relativePath = null;
+
+ // not a managed asset
+ if (!key.StartsWith(this.ManagedPrefix))
+ return false;
+
+ // parse
+ string[] parts = PathUtilities.GetSegments(key, 3);
+ if (parts.Length != 3) // managed key prefix, mod id, relative path
+ return false;
+ contentManagerID = Path.Combine(parts[0], parts[1]);
+ relativePath = parts[2];
+ return true;
+ }
+
+ /// <summary>Get the managed asset key prefix for a mod.</summary>
+ /// <param name="modID">The mod's unique ID.</param>
+ public string GetManagedAssetPrefix(string modID)
+ {
+ return Path.Combine(this.ManagedPrefix, modID.ToLower());
+ }
+
+ /// <summary>Get a copy of an asset from a mod folder.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="internalKey">The internal asset key.</param>
+ /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
+ /// <param name="relativePath">The internal SMAPI asset key.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ public T LoadAndCloneManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LocalizedContentManager.LanguageCode language)
+ {
+ // get content manager
+ IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.Name == contentManagerID);
+ if (contentManager == null)
+ throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
+
+ // get cloned asset
+ T data = contentManager.Load<T>(internalKey, language);
+ return contentManager.CloneIfPossible(data);
+ }
+
+ /// <summary>Purge assets from the cache that match one of the interceptors.</summary>
+ /// <param name="editors">The asset editors for which to purge matching assets.</param>
+ /// <param name="loaders">The asset loaders for which to purge matching assets.</param>
+ /// <returns>Returns the invalidated asset names.</returns>
+ public IEnumerable<string> InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
+ {
+ if (!editors.Any() && !loaders.Any())
+ return new string[0];
+
+ // get CanEdit/Load methods
+ MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
+ MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
+ if (canEdit == null || canLoad == null)
+ throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
+
+ // invalidate matching keys
+ return this.InvalidateCache(asset =>
+ {
+ // check loaders
+ MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
+ foreach (IAssetLoader loader in loaders)
+ {
+ try
+ {
+ if ((bool)canLoadGeneric.Invoke(loader, new object[] { asset }))
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.GetModFor(loader).LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ // check editors
+ MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
+ foreach (IAssetEditor editor in editors)
+ {
+ try
+ {
+ if ((bool)canEditGeneric.Invoke(editor, new object[] { asset }))
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.GetModFor(editor).LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ // asset not affected by a loader or editor
+ return false;
+ });
+ }
+
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+ /// <returns>Returns the invalidated asset keys.</returns>
+ public IEnumerable<string> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false)
+ {
+ string locale = this.GetLocale();
+ return this.InvalidateCache((assetName, type) =>
+ {
+ IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormaliseAssetName);
+ return predicate(info);
+ });
+ }
+
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+ /// <returns>Returns the invalidated asset names.</returns>
+ public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
+ {
+ // invalidate cache
+ HashSet<string> removedAssetNames = new HashSet<string>();
+ foreach (IContentManager contentManager in this.ContentManagers)
+ {
+ foreach (string name in contentManager.InvalidateCache(predicate, dispose))
+ removedAssetNames.Add(name);
+ }
+
+ // reload core game assets
+ int reloaded = 0;
+ foreach (string key in removedAssetNames)
+ {
+ if (this.CoreAssets.Propagate(this.MainContentManager, key)) // use an intercepted content manager
+ reloaded++;
+ }
+
+ // report result
+ if (removedAssetNames.Any())
+ this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
+ else
+ this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
+
+ return removedAssetNames;
+ }
+
+ /// <summary>Dispose held resources.</summary>
+ public void Dispose()
+ {
+ if (this.IsDisposed)
+ return;
+ this.IsDisposed = true;
+
+ this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point.", LogLevel.Trace);
+ foreach (IContentManager contentManager in this.ContentManagers)
+ contentManager.Dispose();
+ this.ContentManagers.Clear();
+ this.MainContentManager = null;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A callback invoked when a content manager is disposed.</summary>
+ /// <param name="contentManager">The content manager being disposed.</param>
+ private void OnDisposing(IContentManager contentManager)
+ {
+ if (this.IsDisposed)
+ return;
+
+ this.ContentManagers.Remove(contentManager);
+ }
+
+ /// <summary>Get the mod which registered an asset loader.</summary>
+ /// <param name="loader">The asset loader.</param>
+ /// <exception cref="KeyNotFoundException">The given loader couldn't be matched to a mod.</exception>
+ private IModMetadata GetModFor(IAssetLoader loader)
+ {
+ foreach (var pair in this.Loaders)
+ {
+ if (pair.Value.Contains(loader))
+ return pair.Key;
+ }
+
+ throw new KeyNotFoundException("This loader isn't associated with a known mod.");
+ }
+
+ /// <summary>Get the mod which registered an asset editor.</summary>
+ /// <param name="editor">The asset editor.</param>
+ /// <exception cref="KeyNotFoundException">The given editor couldn't be matched to a mod.</exception>
+ private IModMetadata GetModFor(IAssetEditor editor)
+ {
+ foreach (var pair in this.Editors)
+ {
+ if (pair.Value.Contains(editor))
+ return pair.Key;
+ }
+
+ throw new KeyNotFoundException("This editor isn't associated with a known mod.");
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ContentCore.cs b/src/SMAPI/Framework/ContentCore.cs
deleted file mode 100644
index 43357553..00000000
--- a/src/SMAPI/Framework/ContentCore.cs
+++ /dev/null
@@ -1,882 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Diagnostics.Contracts;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Reflection;
-using System.Threading;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Content;
-using Microsoft.Xna.Framework.Graphics;
-using StardewModdingAPI.Framework.Content;
-using StardewModdingAPI.Framework.Exceptions;
-using StardewModdingAPI.Framework.Reflection;
-using StardewModdingAPI.Framework.Utilities;
-using StardewModdingAPI.Metadata;
-using StardewValley;
-
-namespace StardewModdingAPI.Framework
-{
- /// <summary>A thread-safe content handler which loads assets with support for mod injection and editing.</summary>
- /// <remarks>
- /// This is the centralised content logic which manages all game assets. The game and mods don't use this class
- /// directly; instead they use one of several <see cref="ContentManagerShim"/> instances, which proxy requests to
- /// this class. That ensures that when the game disposes one content manager, the others can continue unaffected.
- /// That notably requires this class to be thread-safe, since the content managers can be disposed asynchronously.
- ///
- /// Note that assets in the cache have two identifiers: the asset name (like "bundles") and key (like "bundles.pt-BR").
- /// For English and non-translatable assets, these have the same value. The underlying cache only knows about asset
- /// keys, and the game and mods only know about asset names. The content manager handles resolving them.
- /// </remarks>
- internal class ContentCore : IDisposable
- {
- /*********
- ** Properties
- *********/
- /// <summary>The underlying content manager.</summary>
- private readonly LocalizedContentManager Content;
-
- /// <summary>Encapsulates monitoring and logging.</summary>
- private readonly IMonitor Monitor;
-
- /// <summary>The underlying asset cache.</summary>
- private readonly ContentCache Cache;
-
-#if STARDEW_VALLEY_1_3
- /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary>
- private readonly IDictionary<string, bool> IsLocalisableLookup;
-#endif
-
- /// <summary>The locale codes used in asset keys indexed by enum value.</summary>
- private readonly IDictionary<LocalizedContentManager.LanguageCode, string> Locales;
-
- /// <summary>The language enum values indexed by locale code.</summary>
- private readonly IDictionary<string, LocalizedContentManager.LanguageCode> LanguageCodes;
-
- /// <summary>Provides metadata for core game assets.</summary>
- private readonly CoreAssetPropagator CoreAssets;
-
- /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary>
- private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
-
- /// <summary>A lookup of the content managers which loaded each asset.</summary>
- private readonly IDictionary<string, HashSet<ContentManager>> ContentManagersByAssetKey = new Dictionary<string, HashSet<ContentManager>>();
-
- /// <summary>The path prefix for assets in mod folders.</summary>
- private readonly string ModContentPrefix;
-
- /// <summary>A lock used to prevents concurrent changes to the cache while data is being read.</summary>
- private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
-
-
- /*********
- ** Accessors
- *********/
- /// <summary>The current language as a constant.</summary>
- public LocalizedContentManager.LanguageCode Language => this.Content.GetCurrentLanguage();
-
- /// <summary>Interceptors which provide the initial versions of matching assets.</summary>
- public IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>();
-
- /// <summary>Interceptors which edit matching assets after they're loaded.</summary>
- public IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>();
-
- /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
- public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.Content.RootDirectory);
-
- /*********
- ** Public methods
- *********/
- /****
- ** Constructor
- ****/
- /// <summary>Construct an instance.</summary>
- /// <param name="serviceProvider">The service provider to use to locate services.</param>
- /// <param name="rootDirectory">The root directory to search for content.</param>
- /// <param name="currentCulture">The current culture for which to localise content.</param>
- /// <param name="languageCodeOverride">The current language code for which to localise content.</param>
- /// <param name="monitor">Encapsulates monitoring and logging.</param>
- /// <param name="reflection">Simplifies access to private code.</param>
- public ContentCore(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor, Reflector reflection)
- {
- // init
- this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
- this.Content = new LocalizedContentManager(serviceProvider, rootDirectory, currentCulture, languageCodeOverride);
- this.Cache = new ContentCache(this.Content, reflection);
- this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath);
-
- // get asset data
- this.CoreAssets = new CoreAssetPropagator(this.NormaliseAssetName, reflection);
- this.Locales = this.GetKeyLocales(reflection);
- this.LanguageCodes = this.Locales.ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
-#if STARDEW_VALLEY_1_3
- this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this.Content, "_localizedAsset").GetValue();
-#endif
- }
-
- /// <summary>Get a new content manager which defers loading to the content core.</summary>
- /// <param name="name">The content manager's name for logs (if any).</param>
- /// <param name="rootDirectory">The root directory to search for content (or <c>null</c>. for the default)</param>
- public ContentManagerShim CreateContentManager(string name, string rootDirectory = null)
- {
- return new ContentManagerShim(this, name, this.Content.ServiceProvider, rootDirectory ?? this.Content.RootDirectory, this.Content.CurrentCulture, this.Content.LanguageCodeOverride);
- }
-
- /****
- ** Asset key/name handling
- ****/
- /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary>
- /// <param name="path">The file path to normalise.</param>
- [Pure]
- public string NormalisePathSeparators(string path)
- {
- return this.Cache.NormalisePathSeparators(path);
- }
-
- /// <summary>Normalise an asset name so it's consistent with the underlying cache.</summary>
- /// <param name="assetName">The asset key.</param>
- [Pure]
- public string NormaliseAssetName(string assetName)
- {
- return this.Cache.NormaliseKey(assetName);
- }
-
- /// <summary>Assert that the given key has a valid format.</summary>
- /// <param name="key">The asset key to check.</param>
- /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception>
- [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
- public void AssertValidAssetKeyFormat(string key)
- {
- if (string.IsNullOrWhiteSpace(key))
- throw new ArgumentException("The asset key or local path is empty.");
- if (key.Intersect(Path.GetInvalidPathChars()).Any())
- throw new ArgumentException("The asset key or local path contains invalid characters.");
- }
-
- /// <summary>Convert an absolute file path into a appropriate asset name.</summary>
- /// <param name="absolutePath">The absolute path to the file.</param>
- public string GetAssetNameFromFilePath(string absolutePath)
- {
-#if SMAPI_FOR_WINDOWS
- // XNA doesn't allow absolute asset paths, so get a path relative to the content folder
- return this.GetRelativePath(absolutePath);
-#else
- // MonoGame is weird about relative paths on Mac, but allows absolute paths
- return absolutePath;
-#endif
- }
-
- /****
- ** Content loading
- ****/
- /// <summary>Get the current content locale.</summary>
- public string GetLocale()
- {
- return this.GetLocale(this.Content.GetCurrentLanguage());
- }
-
- /// <summary>The locale for a language.</summary>
- /// <param name="language">The language.</param>
- public string GetLocale(LocalizedContentManager.LanguageCode language)
- {
- return this.Locales[language];
- }
-
- /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- public bool IsLoaded(string assetName)
- {
- assetName = this.Cache.NormaliseKey(assetName);
- return this.WithReadLock(() => this.IsNormalisedKeyLoaded(assetName));
- }
-
- /// <summary>Get the cached asset keys.</summary>
- public IEnumerable<string> GetAssetKeys()
- {
- return this.WithReadLock(() =>
- this.Cache.Keys
- .Select(this.GetAssetName)
- .Distinct()
- );
- }
-
- /// <summary>Load an asset through the content pipeline. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
- /// <typeparam name="T">The expected asset type.</typeparam>
- /// <param name="assetName">The asset path relative to the content directory.</param>
- /// <param name="instance">The content manager instance for which to load the asset.</param>
- /// <param name="language">The language code for which to load content.</param>
- /// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception>
- /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception>
- public T Load<T>(string assetName, ContentManager instance
-#if STARDEW_VALLEY_1_3
- , LocalizedContentManager.LanguageCode language
-#endif
- )
- {
- // normalise asset key
- this.AssertValidAssetKeyFormat(assetName);
- assetName = this.NormaliseAssetName(assetName);
-
- // load game content
- if (!assetName.StartsWith(this.ModContentPrefix))
-#if STARDEW_VALLEY_1_3
- return this.LoadImpl<T>(assetName, instance, language);
-#else
- return this.LoadImpl<T>(assetName, instance);
-#endif
-
- // load mod content
- SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}");
- try
- {
- return this.WithWriteLock(() =>
- {
- // try cache
- if (this.IsLoaded(assetName))
-#if STARDEW_VALLEY_1_3
- return this.LoadImpl<T>(assetName, instance, language);
-#else
- return this.LoadImpl<T>(assetName, instance);
-#endif
-
- // get file
- FileInfo file = this.GetModFile(assetName);
- if (!file.Exists)
- throw GetContentError("the specified path doesn't exist.");
-
- // load content
- switch (file.Extension.ToLower())
- {
- // XNB file
- case ".xnb":
-#if STARDEW_VALLEY_1_3
- return this.LoadImpl<T>(assetName, instance, language);
-#else
- return this.LoadImpl<T>(assetName, instance);
-#endif
-
- // unpacked map
- case ".tbin":
- throw GetContentError($"can't read unpacked map file '{assetName}' directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper.");
-
- // unpacked image
- case ".png":
- // validate
- if (typeof(T) != typeof(Texture2D))
- throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
-
- // fetch & cache
- using (FileStream stream = File.OpenRead(file.FullName))
- {
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
- this.InjectWithoutLock(assetName, texture, instance);
- return (T)(object)texture;
- }
-
- default:
- throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'.");
- }
- });
- }
- catch (Exception ex) when (!(ex is SContentLoadException))
- {
- if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib")
- throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
- throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex);
- }
- }
-
- /// <summary>Inject an asset into the cache.</summary>
- /// <typeparam name="T">The type of asset to inject.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="value">The asset value.</param>
- /// <param name="instance">The content manager instance for which to load the asset.</param>
- public void Inject<T>(string assetName, T value, ContentManager instance)
- {
- this.WithWriteLock(() => this.InjectWithoutLock(assetName, value, instance));
- }
-
- /****
- ** Cache invalidation
- ****/
- /// <summary>Purge assets from the cache that match one of the interceptors.</summary>
- /// <param name="editors">The asset editors for which to purge matching assets.</param>
- /// <param name="loaders">The asset loaders for which to purge matching assets.</param>
- /// <returns>Returns whether any cache entries were invalidated.</returns>
- public bool InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
- {
- if (!editors.Any() && !loaders.Any())
- return false;
-
- // get CanEdit/Load methods
- MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
- MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
- if (canEdit == null || canLoad == null)
- throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
-
- // invalidate matching keys
- return this.InvalidateCache(asset =>
- {
- // check loaders
- MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
- if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { asset })))
- return true;
-
- // check editors
- MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
- return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { asset }));
- });
- }
-
- /// <summary>Purge matched assets from the cache.</summary>
- /// <param name="predicate">Matches the asset keys to invalidate.</param>
- /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
- /// <returns>Returns whether any cache entries were invalidated.</returns>
- public bool InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false)
- {
- string locale = this.GetLocale();
- return this.InvalidateCache((assetName, type) =>
- {
- IAssetInfo info = new AssetInfo(locale, assetName, type, this.NormaliseAssetName);
- return predicate(info);
- });
- }
-
- /// <summary>Purge matched assets from the cache.</summary>
- /// <param name="predicate">Matches the asset keys to invalidate.</param>
- /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
- /// <returns>Returns whether any cache entries were invalidated.</returns>
- public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
- {
- return this.WithWriteLock(() =>
- {
- // invalidate matching keys
- HashSet<string> removeKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
- HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
- this.Cache.Remove((key, type) =>
- {
- this.ParseCacheKey(key, out string assetName, out _);
- if (removeAssetNames.Contains(assetName) || predicate(assetName, type))
- {
- removeAssetNames.Add(assetName);
- removeKeys.Add(key);
- return true;
- }
- return false;
- });
-
- // update reference tracking
- foreach (string key in removeKeys)
- this.ContentManagersByAssetKey.Remove(key);
-
- // reload core game assets
- int reloaded = 0;
- foreach (string key in removeAssetNames)
- {
- if (this.CoreAssets.Propagate(Game1.content, key)) // use an intercepted content manager
- reloaded++;
- }
-
- // report result
- if (removeKeys.Any())
- {
- this.Monitor.Log($"Invalidated {removeAssetNames.Count} asset names: {string.Join(", ", removeKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
- return true;
- }
- this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
- return false;
- });
- }
-
- /****
- ** Disposal
- ****/
- /// <summary>Dispose assets for the given content manager shim.</summary>
- /// <param name="shim">The content manager whose assets to dispose.</param>
- internal void DisposeFor(ContentManagerShim shim)
- {
- this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace);
-
- this.WithWriteLock(() =>
- {
- foreach (var entry in this.ContentManagersByAssetKey)
- entry.Value.Remove(shim);
- this.InvalidateCache((key, type) => !this.ContentManagersByAssetKey[key].Any(), dispose: true);
- });
- }
-
-
- /*********
- ** Private methods
- *********/
- /****
- ** Disposal
- ****/
- /// <summary>Dispose held resources.</summary>
- public void Dispose()
- {
- this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
- this.Content.Dispose();
- }
-
- /****
- ** Asset name/key handling
- ****/
- /// <summary>Get a directory or file path relative to the content root.</summary>
- /// <param name="targetPath">The target file path.</param>
- private string GetRelativePath(string targetPath)
- {
- return PathUtilities.GetRelativePath(this.FullRootDirectory, targetPath);
- }
-
- /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
- /// <param name="reflection">Simplifies access to private game code.</param>
- private IDictionary<LocalizedContentManager.LanguageCode, string> GetKeyLocales(Reflector reflection)
- {
-#if !STARDEW_VALLEY_1_3
- IReflectedField<LocalizedContentManager.LanguageCode> codeField = reflection.GetField<LocalizedContentManager.LanguageCode>(typeof(LocalizedContentManager), "_currentLangCode");
- LocalizedContentManager.LanguageCode previousCode = codeField.GetValue();
-#endif
- string previousOverride = this.Content.LanguageCodeOverride;
-
- try
- {
- // temporarily disable language override
- this.Content.LanguageCodeOverride = null;
-
- // create locale => code map
- IReflectedMethod languageCodeString = reflection
-#if STARDEW_VALLEY_1_3
- .GetMethod(this.Content, "languageCodeString");
-#else
- .GetMethod(this.Content, "languageCode");
-#endif
- IDictionary<LocalizedContentManager.LanguageCode, string> map = new Dictionary<LocalizedContentManager.LanguageCode, string>();
- foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode)))
- {
-#if STARDEW_VALLEY_1_3
- map[code] = languageCodeString.Invoke<string>(code);
-#else
- codeField.SetValue(code);
- map[code] = languageCodeString.Invoke<string>();
-#endif
- }
-
- return map;
- }
- finally
- {
- // restore previous settings
- this.Content.LanguageCodeOverride = previousOverride;
-#if !STARDEW_VALLEY_1_3
- codeField.SetValue(previousCode);
-#endif
-
- }
- }
-
- /// <summary>Get the asset name from a cache key.</summary>
- /// <param name="cacheKey">The input cache key.</param>
- private string GetAssetName(string cacheKey)
- {
- this.ParseCacheKey(cacheKey, out string assetName, out string _);
- return assetName;
- }
-
- /// <summary>Parse a cache key into its component parts.</summary>
- /// <param name="cacheKey">The input cache key.</param>
- /// <param name="assetName">The original asset name.</param>
- /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param>
- private void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
- {
- // handle localised key
- if (!string.IsNullOrWhiteSpace(cacheKey))
- {
- int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture);
- if (lastSepIndex >= 0)
- {
- string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
- if (this.LanguageCodes.ContainsKey(suffix))
- {
- assetName = cacheKey.Substring(0, lastSepIndex);
- localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
- return;
- }
- }
- }
-
- // handle simple key
- assetName = cacheKey;
- localeCode = null;
- }
-
- /****
- ** Cache handling
- ****/
- /// <summary>Get whether an asset has already been loaded.</summary>
- /// <param name="normalisedAssetName">The normalised asset name.</param>
- private bool IsNormalisedKeyLoaded(string normalisedAssetName)
- {
-#if STARDEW_VALLEY_1_3
- if (!this.IsLocalisableLookup.TryGetValue(normalisedAssetName, out bool localisable))
- return false;
-
- return localisable
- ? this.Cache.ContainsKey($"{normalisedAssetName}.{this.Locales[this.Content.GetCurrentLanguage()]}")
- : this.Cache.ContainsKey(normalisedAssetName);
-#else
- return
- this.Cache.ContainsKey(normalisedAssetName)
- || this.Cache.ContainsKey($"{normalisedAssetName}.{this.Locales[this.Content.GetCurrentLanguage()]}"); // translated asset
-#endif
- }
-
- /// <summary>Track that a content manager loaded an asset.</summary>
- /// <param name="key">The asset key that was loaded.</param>
- /// <param name="manager">The content manager that loaded the asset.</param>
- private void TrackAssetLoader(string key, ContentManager manager)
- {
- if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet<ContentManager> hash))
- hash = this.ContentManagersByAssetKey[key] = new HashSet<ContentManager>();
- hash.Add(manager);
- }
-
- /****
- ** Content loading
- ****/
- /// <summary>Load an asset name without heuristics to support mod content.</summary>
- /// <typeparam name="T">The type of asset to load.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="instance">The content manager instance for which to load the asset.</param>
- /// <param name="language">The language code for which to load content.</param>
- private T LoadImpl<T>(string assetName, ContentManager instance
-#if STARDEW_VALLEY_1_3
- , LocalizedContentManager.LanguageCode language
-#endif
- )
- {
- return this.WithWriteLock(() =>
- {
- // skip if already loaded
- if (this.IsNormalisedKeyLoaded(assetName))
- {
- this.TrackAssetLoader(assetName, instance);
- return this.Content
-
-#if STARDEW_VALLEY_1_3
- .Load<T>(assetName, language);
-#else
- .Load<T>(assetName);
-#endif
- }
-
- // load asset
- T data;
- if (this.AssetsBeingLoaded.Contains(assetName))
- {
- this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
- this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace);
- data = this.Content
-#if STARDEW_VALLEY_1_3
- .Load<T>(assetName, language);
-#else
- .Load<T>(assetName);
-#endif
- }
- else
- {
- data = this.AssetsBeingLoaded.Track(assetName, () =>
- {
- string locale =
-#if STARDEW_VALLEY_1_3
- this.GetLocale(language);
-#else
- this.GetLocale();
-#endif
- IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.NormaliseAssetName);
- IAssetData asset = this.ApplyLoader<T>(info)
-#if STARDEW_VALLEY_1_3
- ?? new AssetDataForObject(info, this.Content.Load<T>(assetName, language), this.NormaliseAssetName);
-#else
- ?? new AssetDataForObject(info, this.Content.Load<T>(assetName), this.NormaliseAssetName);
-#endif
- asset = this.ApplyEditors<T>(info, asset);
- return (T)asset.Data;
- });
- }
-
- // update cache & return data
- this.InjectWithoutLock(assetName, data, instance);
- return data;
- });
- }
-
- /// <summary>Inject an asset into the cache without acquiring a write lock. This should only be called from within a write lock.</summary>
- /// <typeparam name="T">The type of asset to inject.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="value">The asset value.</param>
- /// <param name="instance">The content manager instance for which to load the asset.</param>
- private void InjectWithoutLock<T>(string assetName, T value, ContentManager instance)
- {
- assetName = this.NormaliseAssetName(assetName);
- this.Cache[assetName] = value;
- this.TrackAssetLoader(assetName, instance);
- }
-
- /// <summary>Get a file from the mod folder.</summary>
- /// <param name="path">The asset path relative to the content folder.</param>
- private FileInfo GetModFile(string path)
- {
- // try exact match
- FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path));
-
- // try with default extension
- if (!file.Exists && file.Extension.ToLower() != ".xnb")
- {
- FileInfo result = new FileInfo(file.FullName + ".xnb");
- if (result.Exists)
- file = result;
- }
-
- return file;
- }
-
- /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary>
- /// <param name="info">The basic asset metadata.</param>
- /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns>
- private IAssetData ApplyLoader<T>(IAssetInfo info)
- {
- // find matching loaders
- var loaders = this.GetInterceptors(this.Loaders)
- .Where(entry =>
- {
- try
- {
- return entry.Value.CanLoad<T>(info);
- }
- catch (Exception ex)
- {
- entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
- return false;
- }
- })
- .ToArray();
-
- // validate loaders
- if (!loaders.Any())
- return null;
- if (loaders.Length > 1)
- {
- string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray();
- this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
- return null;
- }
-
- // fetch asset from loader
- IModMetadata mod = loaders[0].Key;
- IAssetLoader loader = loaders[0].Value;
- T data;
- try
- {
- data = loader.Load<T>(info);
- this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace);
- }
- catch (Exception ex)
- {
- mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
- return null;
- }
-
- // validate asset
- if (data == null)
- {
- mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error);
- return null;
- }
-
- // return matched asset
- return new AssetDataForObject(info, data, this.NormaliseAssetName);
- }
-
- /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary>
- /// <typeparam name="T">The asset type.</typeparam>
- /// <param name="info">The basic asset metadata.</param>
- /// <param name="asset">The loaded asset.</param>
- private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset)
- {
- IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.NormaliseAssetName);
-
- // edit asset
- foreach (var entry in this.GetInterceptors(this.Editors))
- {
- // check for match
- IModMetadata mod = entry.Key;
- IAssetEditor editor = entry.Value;
- try
- {
- if (!editor.CanEdit<T>(info))
- continue;
- }
- catch (Exception ex)
- {
- mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
- continue;
- }
-
- // try edit
- object prevAsset = asset.Data;
- try
- {
- editor.Edit<T>(asset);
- this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace);
- }
- catch (Exception ex)
- {
- mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
- }
-
- // validate edit
- if (asset.Data == null)
- {
- mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn);
- asset = GetNewData(prevAsset);
- }
- else if (!(asset.Data is T))
- {
- mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
- asset = GetNewData(prevAsset);
- }
- }
-
- // return result
- return asset;
- }
-
- /// <summary>Get all registered interceptors from a list.</summary>
- private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
- {
- foreach (var entry in entries)
- {
- IModMetadata mod = entry.Key;
- IList<T> interceptors = entry.Value;
-
- // registered editors
- foreach (T interceptor in interceptors)
- yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
- }
- }
-
- /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary>
- /// <param name="texture">The texture to premultiply.</param>
- /// <returns>Returns a premultiplied texture.</returns>
- /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks>
- private Texture2D PremultiplyTransparency(Texture2D texture)
- {
- // validate
- if (Context.IsInDrawLoop)
- throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop.");
-
- // process texture
- SpriteBatch spriteBatch = Game1.spriteBatch;
- GraphicsDevice gpu = Game1.graphics.GraphicsDevice;
- using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height))
- {
- // create blank render target to premultiply
- gpu.SetRenderTarget(renderTarget);
- gpu.Clear(Color.Black);
-
- // multiply each color by the source alpha, and write just the color values into the final texture
- spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
- {
- ColorDestinationBlend = Blend.Zero,
- ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue,
- AlphaDestinationBlend = Blend.Zero,
- AlphaSourceBlend = Blend.SourceAlpha,
- ColorSourceBlend = Blend.SourceAlpha
- });
- spriteBatch.Draw(texture, texture.Bounds, Color.White);
- spriteBatch.End();
-
- // copy the alpha values from the source texture into the final one without multiplying them
- spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
- {
- ColorWriteChannels = ColorWriteChannels.Alpha,
- AlphaDestinationBlend = Blend.Zero,
- ColorDestinationBlend = Blend.Zero,
- AlphaSourceBlend = Blend.One,
- ColorSourceBlend = Blend.One
- });
- spriteBatch.Draw(texture, texture.Bounds, Color.White);
- spriteBatch.End();
-
- // release GPU
- gpu.SetRenderTarget(null);
-
- // extract premultiplied data
- Color[] data = new Color[texture.Width * texture.Height];
- renderTarget.GetData(data);
-
- // unset texture from GPU to regain control
- gpu.Textures[0] = null;
-
- // update texture with premultiplied data
- texture.SetData(data);
- }
-
- return texture;
- }
-
- /****
- ** Concurrency logic
- ****/
- /// <summary>Acquire a read lock which prevents concurrent writes to the cache while it's open.</summary>
- /// <typeparam name="T">The action's return value.</typeparam>
- /// <param name="action">The action to perform.</param>
- private T WithReadLock<T>(Func<T> action)
- {
- try
- {
- this.Lock.EnterReadLock();
- return action();
- }
- finally
- {
- this.Lock.ExitReadLock();
- }
- }
-
- /// <summary>Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.</summary>
- /// <param name="action">The action to perform.</param>
- private void WithWriteLock(Action action)
- {
- try
- {
- this.Lock.EnterWriteLock();
- action();
- }
- finally
- {
- this.Lock.ExitWriteLock();
- }
- }
-
- /// <summary>Acquire a write lock which prevents concurrent reads or writes to the cache while it's open.</summary>
- /// <typeparam name="T">The action's return value.</typeparam>
- /// <param name="action">The action to perform.</param>
- private T WithWriteLock<T>(Func<T> action)
- {
- try
- {
- this.Lock.EnterWriteLock();
- return action();
- }
- finally
- {
- this.Lock.ExitWriteLock();
- }
- }
- }
-}
diff --git a/src/SMAPI/Framework/ContentManagerShim.cs b/src/SMAPI/Framework/ContentManagerShim.cs
deleted file mode 100644
index 8f88fc2d..00000000
--- a/src/SMAPI/Framework/ContentManagerShim.cs
+++ /dev/null
@@ -1,100 +0,0 @@
-using System;
-using System.Globalization;
-using StardewValley;
-
-namespace StardewModdingAPI.Framework
-{
- /// <summary>A minimal content manager which defers to SMAPI's core content logic.</summary>
- internal class ContentManagerShim : LocalizedContentManager
- {
- /*********
- ** Properties
- *********/
- /// <summary>SMAPI's core content logic.</summary>
- private readonly ContentCore ContentCore;
-
-
- /*********
- ** Accessors
- *********/
- /// <summary>The content manager's name for logs (if any).</summary>
- public string Name { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="contentCore">SMAPI's core content logic.</param>
- /// <param name="name">The content manager's name for logs (if any).</param>
- /// <param name="serviceProvider">The service provider to use to locate services.</param>
- /// <param name="rootDirectory">The root directory to search for content.</param>
- /// <param name="currentCulture">The current culture for which to localise content.</param>
- /// <param name="languageCodeOverride">The current language code for which to localise content.</param>
- public ContentManagerShim(ContentCore contentCore, string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride)
- : base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride)
- {
- this.ContentCore = contentCore;
- this.Name = name;
- }
-
- /// <summary>Load an asset that has been processed by the content pipeline.</summary>
- /// <typeparam name="T">The type of asset to load.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- public override T Load<T>(string assetName)
- {
-#if STARDEW_VALLEY_1_3
- return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode);
-#else
- return this.ContentCore.Load<T>(assetName, this);
-#endif
- }
-
-#if STARDEW_VALLEY_1_3
- /// <summary>Load an asset that has been processed by the content pipeline.</summary>
- /// <typeparam name="T">The type of asset to load.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="language">The language code for which to load content.</param>
- public override T Load<T>(string assetName, LanguageCode language)
- {
- return this.ContentCore.Load<T>(assetName, this, language);
- }
-
- /// <summary>Load the base asset without localisation.</summary>
- /// <typeparam name="T">The type of asset to load.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- public override T LoadBase<T>(string assetName)
- {
- return this.Load<T>(assetName, LanguageCode.en);
- }
-#endif
-
- /// <summary>Inject an asset into the cache.</summary>
- /// <typeparam name="T">The type of asset to inject.</typeparam>
- /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
- /// <param name="value">The asset value.</param>
- public void Inject<T>(string assetName, T value)
- {
- this.ContentCore.Inject<T>(assetName, value, this);
- }
-
-#if STARDEW_VALLEY_1_3
- /// <summary>Create a new content manager for temporary use.</summary>
- public override LocalizedContentManager CreateTemporary()
- {
- return this.ContentCore.CreateContentManager("(temporary)");
- }
-#endif
-
-
- /*********
- ** Protected methods
- *********/
- /// <summary>Dispose held resources.</summary>
- /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
- protected override void Dispose(bool disposing)
- {
- this.ContentCore.DisposeFor(this);
- }
- }
-}
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
new file mode 100644
index 00000000..18aae05b
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -0,0 +1,297 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Microsoft.Xna.Framework.Content;
+using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
+ internal abstract class BaseContentManager : LocalizedContentManager, IContentManager
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The central coordinator which manages content managers.</summary>
+ protected readonly ContentCoordinator Coordinator;
+
+ /// <summary>The underlying asset cache.</summary>
+ protected readonly ContentCache Cache;
+
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ protected readonly IMonitor Monitor;
+
+ /// <summary>Whether the content coordinator has been disposed.</summary>
+ private bool IsDisposed;
+
+ /// <summary>The language enum values indexed by locale code.</summary>
+ private readonly IDictionary<string, LanguageCode> LanguageCodes;
+
+ /// <summary>A callback to invoke when the content manager is being disposed.</summary>
+ private readonly Action<BaseContentManager> OnDisposing;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A name for the mod manager. Not guaranteed to be unique.</summary>
+ public string Name { get; }
+
+ /// <summary>The current language as a constant.</summary>
+ public LanguageCode Language => this.GetCurrentLanguage();
+
+ /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
+ public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
+
+ /// <summary>Whether this content manager is for a mod folder.</summary>
+ public bool IsModContentManager { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ /// <param name="serviceProvider">The service provider to use to locate services.</param>
+ /// <param name="rootDirectory">The root directory to search for content.</param>
+ /// <param name="currentCulture">The current culture for which to localise content.</param>
+ /// <param name="coordinator">The central coordinator which manages content managers.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
+ /// <param name="isModFolder">Whether this content manager is for a mod folder.</param>
+ protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isModFolder)
+ : base(serviceProvider, rootDirectory, currentCulture)
+ {
+ // init
+ this.Name = name;
+ this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
+ this.Cache = new ContentCache(this, reflection);
+ this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+ this.OnDisposing = onDisposing;
+ this.IsModContentManager = isModFolder;
+
+ // get asset data
+ this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
+ }
+
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ public override T Load<T>(string assetName)
+ {
+ return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode);
+ }
+
+ /// <summary>Load the base asset without localisation.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ public override T LoadBase<T>(string assetName)
+ {
+ return this.Load<T>(assetName, LanguageCode.en);
+ }
+
+ /// <summary>Inject an asset into the cache.</summary>
+ /// <typeparam name="T">The type of asset to inject.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="value">The asset value.</param>
+ public void Inject<T>(string assetName, T value)
+ {
+ assetName = this.AssertAndNormaliseAssetName(assetName);
+ this.Cache[assetName] = value;
+
+ }
+
+ /// <summary>Get a copy of the given asset if supported.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="asset">The asset to clone.</param>
+ public T CloneIfPossible<T>(T asset)
+ {
+ switch (asset as object)
+ {
+ case Texture2D source:
+ {
+ int[] pixels = new int[source.Width * source.Height];
+ source.GetData(pixels);
+
+ Texture2D clone = new Texture2D(source.GraphicsDevice, source.Width, source.Height);
+ clone.SetData(pixels);
+ return (T)(object)clone;
+ }
+
+ case Dictionary<string, string> source:
+ return (T)(object)new Dictionary<string, string>(source);
+
+ case Dictionary<int, string> source:
+ return (T)(object)new Dictionary<int, string>(source);
+
+ default:
+ return asset;
+ }
+ }
+
+ /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary>
+ /// <param name="path">The file path to normalise.</param>
+ [Pure]
+ public string NormalisePathSeparators(string path)
+ {
+ return this.Cache.NormalisePathSeparators(path);
+ }
+
+ /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary>
+ /// <param name="assetName">The asset key to check.</param>
+ /// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception>
+ [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
+ public string AssertAndNormaliseAssetName(string assetName)
+ {
+ // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid
+ // throwing other types like ArgumentException here.
+ if (string.IsNullOrWhiteSpace(assetName))
+ throw new SContentLoadException("The asset key or local path is empty.");
+ if (assetName.Intersect(Path.GetInvalidPathChars()).Any())
+ throw new SContentLoadException("The asset key or local path contains invalid characters.");
+
+ return this.Cache.NormaliseKey(assetName);
+ }
+
+ /****
+ ** Content loading
+ ****/
+ /// <summary>Get the current content locale.</summary>
+ public string GetLocale()
+ {
+ return this.GetLocale(this.GetCurrentLanguage());
+ }
+
+ /// <summary>The locale for a language.</summary>
+ /// <param name="language">The language.</param>
+ public string GetLocale(LanguageCode language)
+ {
+ return this.LanguageCodeString(language);
+ }
+
+ /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ public bool IsLoaded(string assetName)
+ {
+ assetName = this.Cache.NormaliseKey(assetName);
+ return this.IsNormalisedKeyLoaded(assetName);
+ }
+
+ /// <summary>Get the cached asset keys.</summary>
+ public IEnumerable<string> GetAssetKeys()
+ {
+ return this.Cache.Keys
+ .Select(this.GetAssetName)
+ .Distinct();
+ }
+
+ /****
+ ** Cache invalidation
+ ****/
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+ /// <returns>Returns the number of invalidated assets.</returns>
+ public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
+ {
+ HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ this.Cache.Remove((key, type) =>
+ {
+ this.ParseCacheKey(key, out string assetName, out _);
+
+ if (removeAssetNames.Contains(assetName) || predicate(assetName, type))
+ {
+ removeAssetNames.Add(assetName);
+ return true;
+ }
+ return false;
+ });
+
+ return removeAssetNames;
+ }
+
+ /// <summary>Dispose held resources.</summary>
+ /// <param name="isDisposing">Whether the content manager is being disposed (rather than finalized).</param>
+ protected override void Dispose(bool isDisposing)
+ {
+ if (this.IsDisposed)
+ return;
+ this.IsDisposed = true;
+
+ this.OnDisposing(this);
+ base.Dispose(isDisposing);
+ }
+
+ /// <inheritdoc />
+ public override void Unload()
+ {
+ if (this.IsDisposed)
+ return; // base logic doesn't allow unloading twice, which happens due to SMAPI and the game both unloading
+
+ base.Unload();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
+ private IDictionary<LanguageCode, string> GetKeyLocales()
+ {
+ // create locale => code map
+ IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>();
+ foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode)))
+ map[code] = this.GetLocale(code);
+
+ return map;
+ }
+
+ /// <summary>Get the asset name from a cache key.</summary>
+ /// <param name="cacheKey">The input cache key.</param>
+ private string GetAssetName(string cacheKey)
+ {
+ this.ParseCacheKey(cacheKey, out string assetName, out string _);
+ return assetName;
+ }
+
+ /// <summary>Parse a cache key into its component parts.</summary>
+ /// <param name="cacheKey">The input cache key.</param>
+ /// <param name="assetName">The original asset name.</param>
+ /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param>
+ protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
+ {
+ // handle localised key
+ if (!string.IsNullOrWhiteSpace(cacheKey))
+ {
+ int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture);
+ if (lastSepIndex >= 0)
+ {
+ string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
+ if (this.LanguageCodes.ContainsKey(suffix))
+ {
+ assetName = cacheKey.Substring(0, lastSepIndex);
+ localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
+ return;
+ }
+ }
+ }
+
+ // handle simple key
+ assetName = cacheKey;
+ localeCode = null;
+ }
+
+ /// <summary>Get whether an asset has already been loaded.</summary>
+ /// <param name="normalisedAssetName">The normalised asset name.</param>
+ protected abstract bool IsNormalisedKeyLoaded(string normalisedAssetName);
+ }
+}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
new file mode 100644
index 00000000..a53840bc
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using StardewModdingAPI.Framework.Content;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files from the game content folder with support for interception.</summary>
+ internal class GameContentManager : BaseContentManager
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary>
+ private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
+
+ /// <summary>Interceptors which provide the initial versions of matching assets.</summary>
+ private IDictionary<IModMetadata, IList<IAssetLoader>> Loaders => this.Coordinator.Loaders;
+
+ /// <summary>Interceptors which edit matching assets after they're loaded.</summary>
+ private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors;
+
+ /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary>
+ private readonly IDictionary<string, bool> IsLocalisableLookup;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ /// <param name="serviceProvider">The service provider to use to locate services.</param>
+ /// <param name="rootDirectory">The root directory to search for content.</param>
+ /// <param name="currentCulture">The current culture for which to localise content.</param>
+ /// <param name="coordinator">The central coordinator which manages content managers.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
+ public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing)
+ : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: false)
+ {
+ this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue();
+ }
+
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ public override T Load<T>(string assetName, LanguageCode language)
+ {
+ assetName = this.AssertAndNormaliseAssetName(assetName);
+
+ // get from cache
+ if (this.IsLoaded(assetName))
+ return base.Load<T>(assetName, language);
+
+ // get managed asset
+ if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ {
+ T managedAsset = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language);
+ this.Inject(assetName, managedAsset);
+ return managedAsset;
+ }
+
+ // load asset
+ T data;
+ if (this.AssetsBeingLoaded.Contains(assetName))
+ {
+ this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
+ this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace);
+ data = base.Load<T>(assetName, language);
+ }
+ else
+ {
+ data = this.AssetsBeingLoaded.Track(assetName, () =>
+ {
+ string locale = this.GetLocale(language);
+ IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormaliseAssetName);
+ IAssetData asset =
+ this.ApplyLoader<T>(info)
+ ?? new AssetDataForObject(info, base.Load<T>(assetName, language), this.AssertAndNormaliseAssetName);
+ asset = this.ApplyEditors<T>(info, asset);
+ return (T)asset.Data;
+ });
+ }
+
+ // update cache & return data
+ this.Inject(assetName, data);
+ return data;
+ }
+
+ /// <summary>Create a new content manager for temporary use.</summary>
+ public override LocalizedContentManager CreateTemporary()
+ {
+ return this.Coordinator.CreateGameContentManager("(temporary)");
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether an asset has already been loaded.</summary>
+ /// <param name="normalisedAssetName">The normalised asset name.</param>
+ protected override bool IsNormalisedKeyLoaded(string normalisedAssetName)
+ {
+ // default English
+ if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalisedAssetName))
+ return this.Cache.ContainsKey(normalisedAssetName);
+
+ // translated
+ string localeKey = $"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}";
+ if (this.IsLocalisableLookup.TryGetValue(localeKey, out bool localisable))
+ {
+ return localisable
+ ? this.Cache.ContainsKey(localeKey)
+ : this.Cache.ContainsKey(normalisedAssetName);
+ }
+
+ // not loaded yet
+ return false;
+ }
+
+ /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary>
+ /// <param name="info">The basic asset metadata.</param>
+ /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns>
+ private IAssetData ApplyLoader<T>(IAssetInfo info)
+ {
+ // find matching loaders
+ var loaders = this.GetInterceptors(this.Loaders)
+ .Where(entry =>
+ {
+ try
+ {
+ return entry.Value.CanLoad<T>(info);
+ }
+ catch (Exception ex)
+ {
+ entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ return false;
+ }
+ })
+ .ToArray();
+
+ // validate loaders
+ if (!loaders.Any())
+ return null;
+ if (loaders.Length > 1)
+ {
+ string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray();
+ this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
+ return null;
+ }
+
+ // fetch asset from loader
+ IModMetadata mod = loaders[0].Key;
+ IAssetLoader loader = loaders[0].Value;
+ T data;
+ try
+ {
+ data = this.CloneIfPossible(loader.Load<T>(info));
+ this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace);
+ }
+ catch (Exception ex)
+ {
+ mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ return null;
+ }
+
+ // validate asset
+ if (data == null)
+ {
+ mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error);
+ return null;
+ }
+
+ // return matched asset
+ return new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName);
+ }
+
+ /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="info">The basic asset metadata.</param>
+ /// <param name="asset">The loaded asset.</param>
+ private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset)
+ {
+ IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName);
+
+ // edit asset
+ foreach (var entry in this.GetInterceptors(this.Editors))
+ {
+ // check for match
+ IModMetadata mod = entry.Key;
+ IAssetEditor editor = entry.Value;
+ try
+ {
+ if (!editor.CanEdit<T>(info))
+ continue;
+ }
+ catch (Exception ex)
+ {
+ mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ continue;
+ }
+
+ // try edit
+ object prevAsset = asset.Data;
+ try
+ {
+ editor.Edit<T>(asset);
+ this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace);
+ }
+ catch (Exception ex)
+ {
+ mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+
+ // validate edit
+ if (asset.Data == null)
+ {
+ mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn);
+ asset = GetNewData(prevAsset);
+ }
+ else if (!(asset.Data is T))
+ {
+ mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
+ asset = GetNewData(prevAsset);
+ }
+ }
+
+ // return result
+ return asset;
+ }
+
+ /// <summary>Get all registered interceptors from a list.</summary>
+ private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
+ {
+ foreach (var entry in entries)
+ {
+ IModMetadata mod = entry.Key;
+ IList<T> interceptors = entry.Value;
+
+ // registered editors
+ foreach (T interceptor in interceptors)
+ yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
new file mode 100644
index 00000000..1eb8b0ac
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files.</summary>
+ internal interface IContentManager : IDisposable
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A name for the mod manager. Not guaranteed to be unique.</summary>
+ string Name { get; }
+
+ /// <summary>The current language as a constant.</summary>
+ LocalizedContentManager.LanguageCode Language { get; }
+
+ /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
+ string FullRootDirectory { get; }
+
+ /// <summary>Whether this content manager is for a mod folder.</summary>
+ bool IsModContentManager { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ T Load<T>(string assetName);
+
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ T Load<T>(string assetName, LocalizedContentManager.LanguageCode language);
+
+ /// <summary>Inject an asset into the cache.</summary>
+ /// <typeparam name="T">The type of asset to inject.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="value">The asset value.</param>
+ void Inject<T>(string assetName, T value);
+
+ /// <summary>Get a copy of the given asset if supported.</summary>
+ /// <typeparam name="T">The asset type.</typeparam>
+ /// <param name="asset">The asset to clone.</param>
+ T CloneIfPossible<T>(T asset);
+
+ /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary>
+ /// <param name="path">The file path to normalise.</param>
+ [Pure]
+ string NormalisePathSeparators(string path);
+
+ /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary>
+ /// <param name="assetName">The asset key to check.</param>
+ /// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception>
+ [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
+ string AssertAndNormaliseAssetName(string assetName);
+
+ /// <summary>Get the current content locale.</summary>
+ string GetLocale();
+
+ /// <summary>The locale for a language.</summary>
+ /// <param name="language">The language.</param>
+ string GetLocale(LocalizedContentManager.LanguageCode language);
+
+ /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ bool IsLoaded(string assetName);
+
+ /// <summary>Get the cached asset keys.</summary>
+ IEnumerable<string> GetAssetKeys();
+
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
+ /// <returns>Returns the number of invalidated assets.</returns>
+ IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
+ }
+}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
new file mode 100644
index 00000000..80bf37e9
--- /dev/null
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Globalization;
+using System.IO;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ContentManagers
+{
+ /// <summary>A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
+ internal class ModContentManager : BaseContentManager
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
+ /// <param name="serviceProvider">The service provider to use to locate services.</param>
+ /// <param name="rootDirectory">The root directory to search for content.</param>
+ /// <param name="currentCulture">The current culture for which to localise content.</param>
+ /// <param name="coordinator">The central coordinator which manages content managers.</param>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
+ public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing)
+ : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) { }
+
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ public override T Load<T>(string assetName, LanguageCode language)
+ {
+ assetName = this.AssertAndNormaliseAssetName(assetName);
+
+ // get from cache
+ if (this.IsLoaded(assetName))
+ return base.Load<T>(assetName, language);
+
+ // get managed asset
+ if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
+ {
+ if (contentManagerID != this.Name)
+ {
+ T data = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language);
+ this.Inject(assetName, data);
+ return data;
+ }
+
+ return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language);
+ }
+
+ throw new NotSupportedException("Can't load content folder asset from a mod content manager.");
+ }
+
+ /// <summary>Create a new content manager for temporary use.</summary>
+ public override LocalizedContentManager CreateTemporary()
+ {
+ throw new NotSupportedException("Can't create a temporary mod content manager.");
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether an asset has already been loaded.</summary>
+ /// <param name="normalisedAssetName">The normalised asset name.</param>
+ protected override bool IsNormalisedKeyLoaded(string normalisedAssetName)
+ {
+ return this.Cache.ContainsKey(normalisedAssetName);
+ }
+
+ /// <summary>Load a managed mod asset.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="internalKey">The internal asset key.</param>
+ /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param>
+ /// <param name="relativePath">The relative path within the mod folder.</param>
+ /// <param name="language">The language code for which to load content.</param>
+ private T LoadManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LanguageCode language)
+ {
+ SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{relativePath}' from {contentManagerID}: {reasonPhrase}");
+ try
+ {
+ // get file
+ FileInfo file = this.GetModFile(relativePath);
+ if (!file.Exists)
+ throw GetContentError("the specified path doesn't exist.");
+
+ // load content
+ switch (file.Extension.ToLower())
+ {
+ // XNB file
+ case ".xnb":
+ return base.Load<T>(relativePath, language);
+
+ // unpacked map
+ case ".tbin":
+ throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper.");
+
+ // unpacked image
+ case ".png":
+ // validate
+ if (typeof(T) != typeof(Texture2D))
+ throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
+
+ // fetch & cache
+ using (FileStream stream = File.OpenRead(file.FullName))
+ {
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
+ texture = this.PremultiplyTransparency(texture);
+ this.Inject(internalKey, texture);
+ return (T)(object)texture;
+ }
+
+ default:
+ throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'.");
+ }
+ }
+ catch (Exception ex) when (!(ex is SContentLoadException))
+ {
+ if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib")
+ throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
+ throw new SContentLoadException($"The content manager failed loading content asset '{relativePath}' from {contentManagerID}.", ex);
+ }
+ }
+
+ /// <summary>Get a file from the mod folder.</summary>
+ /// <param name="path">The asset path relative to the content folder.</param>
+ private FileInfo GetModFile(string path)
+ {
+ // try exact match
+ FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path));
+
+ // try with default extension
+ if (!file.Exists && file.Extension.ToLower() != ".xnb")
+ {
+ FileInfo result = new FileInfo(file.FullName + ".xnb");
+ if (result.Exists)
+ file = result;
+ }
+
+ return file;
+ }
+
+ /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary>
+ /// <param name="texture">The texture to premultiply.</param>
+ /// <returns>Returns a premultiplied texture.</returns>
+ /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks>
+ private Texture2D PremultiplyTransparency(Texture2D texture)
+ {
+ // validate
+ if (Context.IsInDrawLoop)
+ throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop.");
+
+ // process texture
+ SpriteBatch spriteBatch = Game1.spriteBatch;
+ GraphicsDevice gpu = Game1.graphics.GraphicsDevice;
+ using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height))
+ {
+ // create blank render target to premultiply
+ gpu.SetRenderTarget(renderTarget);
+ gpu.Clear(Color.Black);
+
+ // multiply each color by the source alpha, and write just the color values into the final texture
+ spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
+ {
+ ColorDestinationBlend = Blend.Zero,
+ ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue,
+ AlphaDestinationBlend = Blend.Zero,
+ AlphaSourceBlend = Blend.SourceAlpha,
+ ColorSourceBlend = Blend.SourceAlpha
+ });
+ spriteBatch.Draw(texture, texture.Bounds, Color.White);
+ spriteBatch.End();
+
+ // copy the alpha values from the source texture into the final one without multiplying them
+ spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState
+ {
+ ColorWriteChannels = ColorWriteChannels.Alpha,
+ AlphaDestinationBlend = Blend.Zero,
+ ColorDestinationBlend = Blend.Zero,
+ AlphaSourceBlend = Blend.One,
+ ColorSourceBlend = Blend.One
+ });
+ spriteBatch.Draw(texture, texture.Bounds, Color.White);
+ spriteBatch.End();
+
+ // release GPU
+ gpu.SetRenderTarget(null);
+
+ // extract premultiplied data
+ Color[] data = new Color[texture.Width * texture.Height];
+ renderTarget.GetData(data);
+
+ // unset texture from GPU to regain control
+ gpu.Textures[0] = null;
+
+ // update texture with premultiplied data
+ texture.SetData(data);
+ }
+
+ return texture;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs
index 071fb872..4a4adb90 100644
--- a/src/SMAPI/Framework/ContentPack.cs
+++ b/src/SMAPI/Framework/ContentPack.cs
@@ -2,8 +2,8 @@ using System;
using System.IO;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
-using StardewModdingAPI.Framework.Serialisation;
-using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Utilities;
using xTile;
namespace StardewModdingAPI.Framework
diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs
index db02b3d1..079917f2 100644
--- a/src/SMAPI/Framework/CursorPosition.cs
+++ b/src/SMAPI/Framework/CursorPosition.cs
@@ -8,6 +8,9 @@ namespace StardewModdingAPI.Framework
/*********
** Accessors
*********/
+ /// <summary>The pixel position relative to the top-left corner of the in-game map.</summary>
+ public Vector2 AbsolutePixels { get; }
+
/// <summary>The pixel position relative to the top-left corner of the visible screen.</summary>
public Vector2 ScreenPixels { get; }
@@ -22,14 +25,23 @@ namespace StardewModdingAPI.Framework
** Public methods
*********/
/// <summary>Construct an instance.</summary>
+ /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map.</param>
/// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen.</param>
/// <param name="tile">The tile position relative to the top-left corner of the map.</param>
/// <param name="grabTile">The tile position that the game considers under the cursor for purposes of clicking actions.</param>
- public CursorPosition(Vector2 screenPixels, Vector2 tile, Vector2 grabTile)
+ public CursorPosition(Vector2 absolutePixels, Vector2 screenPixels, Vector2 tile, Vector2 grabTile)
{
+ this.AbsolutePixels = absolutePixels;
this.ScreenPixels = screenPixels;
this.Tile = tile;
this.GrabTile = grabTile;
}
+
+ /// <summary>Get whether the current object is equal to another object of the same type.</summary>
+ /// <param name="other">An object to compare with this object.</param>
+ public bool Equals(ICursorPosition other)
+ {
+ return other != null && this.AbsolutePixels == other.AbsolutePixels;
+ }
}
}
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index d7c89a76..168ddde0 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -9,7 +9,62 @@ namespace StardewModdingAPI.Framework.Events
internal class EventManager
{
/*********
- ** Properties
+ ** Events (new)
+ *********/
+ /****
+ ** Game loop
+ ****/
+ /// <summary>Raised after the game is launched, right before the first update tick.</summary>
+ public readonly ManagedEvent<GameLoopLaunchedEventArgs> GameLoop_Launched;
+
+ /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary>
+ public readonly ManagedEvent<GameLoopUpdatingEventArgs> GameLoop_Updating;
+
+ /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary>
+ public readonly ManagedEvent<GameLoopUpdatedEventArgs> GameLoop_Updated;
+
+ /****
+ ** Input
+ ****/
+ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
+ public readonly ManagedEvent<InputButtonPressedEventArgs> Input_ButtonPressed;
+
+ /// <summary>Raised after the player released a button on the keyboard, controller, or mouse.</summary>
+ public readonly ManagedEvent<InputButtonReleasedEventArgs> Input_ButtonReleased;
+
+ /// <summary>Raised after the player moves the in-game cursor.</summary>
+ public readonly ManagedEvent<InputCursorMovedEventArgs> Input_CursorMoved;
+
+ /// <summary>Raised after the player scrolls the mouse wheel.</summary>
+ public readonly ManagedEvent<InputMouseWheelScrolledEventArgs> Input_MouseWheelScrolled;
+
+ /****
+ ** World
+ ****/
+ /// <summary>Raised after a game location is added or removed.</summary>
+ public readonly ManagedEvent<WorldLocationListChangedEventArgs> World_LocationListChanged;
+
+ /// <summary>Raised after buildings are added or removed in a location.</summary>
+ public readonly ManagedEvent<WorldBuildingListChangedEventArgs> World_BuildingListChanged;
+
+ /// <summary>Raised after debris are added or removed in a location.</summary>
+ public readonly ManagedEvent<WorldDebrisListChangedEventArgs> World_DebrisListChanged;
+
+ /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary>
+ public readonly ManagedEvent<WorldLargeTerrainFeatureListChangedEventArgs> World_LargeTerrainFeatureListChanged;
+
+ /// <summary>Raised after NPCs are added or removed in a location.</summary>
+ public readonly ManagedEvent<WorldNpcListChangedEventArgs> World_NpcListChanged;
+
+ /// <summary>Raised after objects are added or removed in a location.</summary>
+ public readonly ManagedEvent<WorldObjectListChangedEventArgs> World_ObjectListChanged;
+
+ /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
+ public readonly ManagedEvent<WorldTerrainFeatureListChangedEventArgs> World_TerrainFeatureListChanged;
+
+
+ /*********
+ ** Events (old)
*********/
/****
** ContentEvents
@@ -21,28 +76,28 @@ namespace StardewModdingAPI.Framework.Events
** ControlEvents
****/
/// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary>
- public readonly ManagedEvent<EventArgsKeyboardStateChanged> Control_KeyboardChanged;
+ public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_Control_KeyboardChanged;
- /// <summary>Raised when the player presses a keyboard key.</summary>
- public readonly ManagedEvent<EventArgsKeyPressed> Control_KeyPressed;
+ /// <summary>Raised after the player presses a keyboard key.</summary>
+ public readonly ManagedEvent<EventArgsKeyPressed> Legacy_Control_KeyPressed;
- /// <summary>Raised when the player releases a keyboard key.</summary>
- public readonly ManagedEvent<EventArgsKeyPressed> Control_KeyReleased;
+ /// <summary>Raised after the player releases a keyboard key.</summary>
+ public readonly ManagedEvent<EventArgsKeyPressed> Legacy_Control_KeyReleased;
/// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary>
- public readonly ManagedEvent<EventArgsMouseStateChanged> Control_MouseChanged;
+ public readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_Control_MouseChanged;
/// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary>
- public readonly ManagedEvent<EventArgsControllerButtonPressed> Control_ControllerButtonPressed;
+ public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_Control_ControllerButtonPressed;
/// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary>
- public readonly ManagedEvent<EventArgsControllerButtonReleased> Control_ControllerButtonReleased;
+ public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_Control_ControllerButtonReleased;
/// <summary>The player pressed a controller trigger button.</summary>
- public readonly ManagedEvent<EventArgsControllerTriggerPressed> Control_ControllerTriggerPressed;
+ public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_Control_ControllerTriggerPressed;
/// <summary>The player released a controller trigger button.</summary>
- public readonly ManagedEvent<EventArgsControllerTriggerReleased> Control_ControllerTriggerReleased;
+ public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_Control_ControllerTriggerReleased;
/****
** GameEvents
@@ -98,23 +153,23 @@ namespace StardewModdingAPI.Framework.Events
/****
** InputEvents
****/
- /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary>
- public readonly ManagedEvent<EventArgsInput> Input_ButtonPressed;
+ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
+ public readonly ManagedEvent<EventArgsInput> Legacy_Input_ButtonPressed;
- /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary>
- public readonly ManagedEvent<EventArgsInput> Input_ButtonReleased;
+ /// <summary>Raised after the player releases a keyboard key on the keyboard, controller, or mouse.</summary>
+ public readonly ManagedEvent<EventArgsInput> Legacy_Input_ButtonReleased;
/****
** LocationEvents
****/
- /// <summary>Raised after the player warps to a new location.</summary>
- public readonly ManagedEvent<EventArgsCurrentLocationChanged> Location_CurrentLocationChanged;
-
/// <summary>Raised after a game location is added or removed.</summary>
- public readonly ManagedEvent<EventArgsGameLocationsChanged> Location_LocationsChanged;
+ public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_Location_LocationsChanged;
- /// <summary>Raised after the list of objects in the current location changes (e.g. an object is added or removed).</summary>
- public readonly ManagedEvent<EventArgsLocationObjectsChanged> Location_LocationObjectsChanged;
+ /// <summary>Raised after buildings are added or removed in a location.</summary>
+ public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_Location_BuildingsChanged;
+
+ /// <summary>Raised after objects are added or removed in a location.</summary>
+ public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_Location_ObjectsChanged;
/****
** MenuEvents
@@ -126,6 +181,21 @@ namespace StardewModdingAPI.Framework.Events
public readonly ManagedEvent<EventArgsClickableMenuClosed> Menu_Closed;
/****
+ ** MultiplayerEvents
+ ****/
+ /// <summary>Raised before the game syncs changes from other players.</summary>
+ public readonly ManagedEvent Multiplayer_BeforeMainSync;
+
+ /// <summary>Raised after the game syncs changes from other players.</summary>
+ public readonly ManagedEvent Multiplayer_AfterMainSync;
+
+ /// <summary>Raised before the game broadcasts changes to other players.</summary>
+ public readonly ManagedEvent Multiplayer_BeforeMainBroadcast;
+
+ /// <summary>Raised after the game broadcasts changes to other players.</summary>
+ public readonly ManagedEvent Multiplayer_AfterMainBroadcast;
+
+ /****
** MineEvents
****/
/// <summary>Raised after the player warps to a new level of the mine.</summary>
@@ -140,6 +210,10 @@ namespace StardewModdingAPI.Framework.Events
/// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary>
public readonly ManagedEvent<EventArgsLevelUp> Player_LeveledUp;
+ /// <summary>Raised after the player warps to a new location.</summary>
+ public readonly ManagedEvent<EventArgsPlayerWarped> Player_Warped;
+
+
/****
** SaveEvents
****/
@@ -189,17 +263,35 @@ namespace StardewModdingAPI.Framework.Events
ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry);
ManagedEvent ManageEvent(string typeName, string eventName) => new ManagedEvent($"{typeName}.{eventName}", monitor, modRegistry);
- // init events
+ // init events (new)
+ this.GameLoop_Launched = ManageEventOf<GameLoopLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Launched));
+ this.GameLoop_Updating = ManageEventOf<GameLoopUpdatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Updating));
+ this.GameLoop_Updated = ManageEventOf<GameLoopUpdatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Updated));
+
+ this.Input_ButtonPressed = ManageEventOf<InputButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed));
+ this.Input_ButtonReleased = ManageEventOf<InputButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased));
+ this.Input_CursorMoved = ManageEventOf<InputCursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved));
+ this.Input_MouseWheelScrolled = ManageEventOf<InputMouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled));
+
+ this.World_BuildingListChanged = ManageEventOf<WorldBuildingListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LocationListChanged));
+ this.World_DebrisListChanged = ManageEventOf<WorldDebrisListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.DebrisListChanged));
+ this.World_LargeTerrainFeatureListChanged = ManageEventOf<WorldLargeTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LargeTerrainFeatureListChanged));
+ this.World_LocationListChanged = ManageEventOf<WorldLocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged));
+ this.World_NpcListChanged = ManageEventOf<WorldNpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged));
+ this.World_ObjectListChanged = ManageEventOf<WorldObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
+ this.World_TerrainFeatureListChanged = ManageEventOf<WorldTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
+
+ // init events (old)
this.Content_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged));
- this.Control_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed));
- this.Control_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased));
- this.Control_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed));
- this.Control_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased));
- this.Control_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged));
- this.Control_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed));
- this.Control_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased));
- this.Control_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged));
+ this.Legacy_Control_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed));
+ this.Legacy_Control_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased));
+ this.Legacy_Control_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed));
+ this.Legacy_Control_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased));
+ this.Legacy_Control_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged));
+ this.Legacy_Control_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed));
+ this.Legacy_Control_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased));
+ this.Legacy_Control_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged));
this.Game_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick));
this.Game_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick));
@@ -218,20 +310,26 @@ namespace StardewModdingAPI.Framework.Events
this.Graphics_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent));
this.Graphics_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent));
- this.Input_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed));
- this.Input_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased));
+ this.Legacy_Input_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed));
+ this.Legacy_Input_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased));
- this.Location_CurrentLocationChanged = ManageEventOf<EventArgsCurrentLocationChanged>(nameof(LocationEvents), nameof(LocationEvents.CurrentLocationChanged));
- this.Location_LocationsChanged = ManageEventOf<EventArgsGameLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged));
- this.Location_LocationObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationObjectsChanged));
+ this.Legacy_Location_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged));
+ this.Legacy_Location_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged));
+ this.Legacy_Location_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged));
this.Menu_Changed = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged));
this.Menu_Closed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed));
+ this.Multiplayer_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast));
+ this.Multiplayer_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast));
+ this.Multiplayer_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync));
+ this.Multiplayer_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync));
+
this.Mine_LevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged));
this.Player_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged));
this.Player_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp));
+ this.Player_Warped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped));
this.Save_BeforeCreate = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate));
this.Save_AfterCreate = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate));
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index e54a4fd3..c1ebf6c7 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -28,8 +28,16 @@ namespace StardewModdingAPI.Framework.Events
/// <param name="handler">The event handler.</param>
public void Add(EventHandler<TEventArgs> handler)
{
+ this.Add(handler, this.ModRegistry.GetFromStack());
+ }
+
+ /// <summary>Add an event handler.</summary>
+ /// <param name="handler">The event handler.</param>
+ /// <param name="mod">The mod which added the event handler.</param>
+ public void Add(EventHandler<TEventArgs> handler, IModMetadata mod)
+ {
this.Event += handler;
- this.AddTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler<TEventArgs>>());
+ this.AddTracking(mod, handler, this.Event?.GetInvocationList().Cast<EventHandler<TEventArgs>>());
}
/// <summary>Remove an event handler.</summary>
@@ -85,8 +93,16 @@ namespace StardewModdingAPI.Framework.Events
/// <param name="handler">The event handler.</param>
public void Add(EventHandler handler)
{
+ this.Add(handler, this.ModRegistry.GetFromStack());
+ }
+
+ /// <summary>Add an event handler.</summary>
+ /// <param name="handler">The event handler.</param>
+ /// <param name="mod">The mod which added the event handler.</param>
+ public void Add(EventHandler handler, IModMetadata mod)
+ {
this.Event += handler;
- this.AddTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler>());
+ this.AddTracking(mod, handler, this.Event?.GetInvocationList().Cast<EventHandler>());
}
/// <summary>Remove an event handler.</summary>
diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs
index 7e42d613..f3a278dc 100644
--- a/src/SMAPI/Framework/Events/ManagedEventBase.cs
+++ b/src/SMAPI/Framework/Events/ManagedEventBase.cs
@@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.Events
private readonly IMonitor Monitor;
/// <summary>The mod registry with which to identify mods.</summary>
- private readonly ModRegistry ModRegistry;
+ protected readonly ModRegistry ModRegistry;
/// <summary>The display names for the mods which added each delegate.</summary>
private readonly IDictionary<TEventHandler, IModMetadata> SourceMods = new Dictionary<TEventHandler, IModMetadata>();
@@ -50,11 +50,12 @@ namespace StardewModdingAPI.Framework.Events
}
/// <summary>Track an event handler.</summary>
+ /// <param name="mod">The mod which added the handler.</param>
/// <param name="handler">The event handler.</param>
/// <param name="invocationList">The updated event invocation list.</param>
- protected void AddTracking(TEventHandler handler, IEnumerable<TEventHandler> invocationList)
+ protected void AddTracking(IModMetadata mod, TEventHandler handler, IEnumerable<TEventHandler> invocationList)
{
- this.SourceMods[handler] = this.ModRegistry.GetFromStack();
+ this.SourceMods[handler] = mod;
this.CachedInvocationList = invocationList?.ToArray() ?? new TEventHandler[0];
}
@@ -64,7 +65,7 @@ namespace StardewModdingAPI.Framework.Events
protected void RemoveTracking(TEventHandler handler, IEnumerable<TEventHandler> invocationList)
{
this.CachedInvocationList = invocationList?.ToArray() ?? new TEventHandler[0];
- if(!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once)
+ if (!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once)
this.SourceMods.Remove(handler);
}
diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs
new file mode 100644
index 00000000..9e474457
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModEvents.cs
@@ -0,0 +1,34 @@
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Manages access to events raised by SMAPI.</summary>
+ internal class ModEvents : IModEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IModEvents.Input"/> if possible.</summary>
+ public IGameLoopEvents GameLoop { get; }
+
+ /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
+ public IInputEvents Input { get; }
+
+ /// <summary>Events raised when something changes in the world.</summary>
+ public IWorldEvents World { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ public ModEvents(IModMetadata mod, EventManager eventManager)
+ {
+ this.GameLoop = new ModGameLoopEvents(mod, eventManager);
+ this.Input = new ModInputEvents(mod, eventManager);
+ this.World = new ModWorldEvents(mod, eventManager);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ModEventsBase.cs b/src/SMAPI/Framework/Events/ModEventsBase.cs
new file mode 100644
index 00000000..545c58a8
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModEventsBase.cs
@@ -0,0 +1,28 @@
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>An internal base class for event API classes.</summary>
+ internal abstract class ModEventsBase
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying event manager.</summary>
+ protected readonly EventManager EventManager;
+
+ /// <summary>The mod which uses this instance.</summary>
+ protected readonly IModMetadata Mod;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModEventsBase(IModMetadata mod, EventManager eventManager)
+ {
+ this.Mod = mod;
+ this.EventManager = eventManager;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs
new file mode 100644
index 00000000..379a4e96
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs
@@ -0,0 +1,43 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IInputEvents"/> if possible.</summary>
+ internal class ModGameLoopEvents : ModEventsBase, IGameLoopEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Raised after the game is launched, right before the first update tick.</summary>
+ public event EventHandler<GameLoopLaunchedEventArgs> Launched
+ {
+ add => this.EventManager.GameLoop_Launched.Add(value);
+ remove => this.EventManager.GameLoop_Launched.Remove(value);
+ }
+
+ /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary>
+ public event EventHandler<GameLoopUpdatingEventArgs> Updating
+ {
+ add => this.EventManager.GameLoop_Updating.Add(value);
+ remove => this.EventManager.GameLoop_Updating.Remove(value);
+ }
+
+ /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary>
+ public event EventHandler<GameLoopUpdatedEventArgs> Updated
+ {
+ add => this.EventManager.GameLoop_Updated.Add(value);
+ remove => this.EventManager.GameLoop_Updated.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModGameLoopEvents(IModMetadata mod, EventManager eventManager)
+ : base(mod, eventManager) { }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs
new file mode 100644
index 00000000..feca34f3
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModInputEvents.cs
@@ -0,0 +1,50 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
+ internal class ModInputEvents : ModEventsBase, IInputEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary>
+ public event EventHandler<InputButtonPressedEventArgs> ButtonPressed
+ {
+ add => this.EventManager.Input_ButtonPressed.Add(value);
+ remove => this.EventManager.Input_ButtonPressed.Remove(value);
+ }
+
+ /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary>
+ public event EventHandler<InputButtonReleasedEventArgs> ButtonReleased
+ {
+ add => this.EventManager.Input_ButtonReleased.Add(value);
+ remove => this.EventManager.Input_ButtonReleased.Remove(value);
+ }
+
+ /// <summary>Raised after the player moves the in-game cursor.</summary>
+ public event EventHandler<InputCursorMovedEventArgs> CursorMoved
+ {
+ add => this.EventManager.Input_CursorMoved.Add(value);
+ remove => this.EventManager.Input_CursorMoved.Remove(value);
+ }
+
+ /// <summary>Raised after the player scrolls the mouse wheel.</summary>
+ public event EventHandler<InputMouseWheelScrolledEventArgs> MouseWheelScrolled
+ {
+ add => this.EventManager.Input_MouseWheelScrolled.Add(value);
+ remove => this.EventManager.Input_MouseWheelScrolled.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModInputEvents(IModMetadata mod, EventManager eventManager)
+ : base(mod, eventManager) { }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs
new file mode 100644
index 00000000..dc9c0f4c
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs
@@ -0,0 +1,71 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Events raised when something changes in the world.</summary>
+ internal class ModWorldEvents : ModEventsBase, IWorldEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Raised after a game location is added or removed.</summary>
+ public event EventHandler<WorldLocationListChangedEventArgs> LocationListChanged
+ {
+ add => this.EventManager.World_LocationListChanged.Add(value, this.Mod);
+ remove => this.EventManager.World_LocationListChanged.Remove(value);
+ }
+
+ /// <summary>Raised after buildings are added or removed in a location.</summary>
+ public event EventHandler<WorldBuildingListChangedEventArgs> BuildingListChanged
+ {
+ add => this.EventManager.World_BuildingListChanged.Add(value, this.Mod);
+ remove => this.EventManager.World_BuildingListChanged.Remove(value);
+ }
+
+ /// <summary>Raised after debris are added or removed in a location.</summary>
+ public event EventHandler<WorldDebrisListChangedEventArgs> DebrisListChanged
+ {
+ add => this.EventManager.World_DebrisListChanged.Add(value, this.Mod);
+ remove => this.EventManager.World_DebrisListChanged.Remove(value);
+ }
+
+ /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary>
+ public event EventHandler<WorldLargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged
+ {
+ add => this.EventManager.World_LargeTerrainFeatureListChanged.Add(value, this.Mod);
+ remove => this.EventManager.World_LargeTerrainFeatureListChanged.Remove(value);
+ }
+
+ /// <summary>Raised after NPCs are added or removed in a location.</summary>
+ public event EventHandler<WorldNpcListChangedEventArgs> NpcListChanged
+ {
+ add => this.EventManager.World_NpcListChanged.Add(value);
+ remove => this.EventManager.World_NpcListChanged.Remove(value);
+ }
+
+ /// <summary>Raised after objects are added or removed in a location.</summary>
+ public event EventHandler<WorldObjectListChangedEventArgs> ObjectListChanged
+ {
+ add => this.EventManager.World_ObjectListChanged.Add(value);
+ remove => this.EventManager.World_ObjectListChanged.Remove(value);
+ }
+
+ /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
+ public event EventHandler<WorldTerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
+ {
+ add => this.EventManager.World_TerrainFeatureListChanged.Add(value);
+ remove => this.EventManager.World_TerrainFeatureListChanged.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModWorldEvents(IModMetadata mod, EventManager eventManager)
+ : base(mod, eventManager) { }
+ }
+}
diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs
index e5022212..261de374 100644
--- a/src/SMAPI/Framework/GameVersion.cs
+++ b/src/SMAPI/Framework/GameVersion.cs
@@ -49,11 +49,6 @@ namespace StardewModdingAPI.Framework
/// <param name="gameVersion">The game version string.</param>
private static string GetSemanticVersionString(string gameVersion)
{
-#if STARDEW_VALLEY_1_3
- if(gameVersion.StartsWith("1.3.0."))
- return new SemanticVersion(1, 3, 0, "alpha." + gameVersion.Substring("1.3.0.".Length)).ToString();
-#endif
-
return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)
? semanticVersion
: gameVersion;
@@ -63,11 +58,6 @@ namespace StardewModdingAPI.Framework
/// <param name="semanticVersion">The semantic version string.</param>
private static string GetGameVersionString(string semanticVersion)
{
- #if STARDEW_VALLEY_1_3
- if(semanticVersion.StartsWith("1.3-alpha."))
- return "1.3.0." + semanticVersion.Substring("1.3-alpha.".Length);
- #endif
-
foreach (var mapping in GameVersion.VersionMap)
{
if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase))
diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs
index d1e8eb7d..2145105b 100644
--- a/src/SMAPI/Framework/IModMetadata.cs
+++ b/src/SMAPI/Framework/IModMetadata.cs
@@ -1,5 +1,6 @@
-using StardewModdingAPI.Framework.ModData;
using StardewModdingAPI.Framework.ModLoading;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
+using StardewModdingAPI.Toolkit.Framework.ModData;
namespace StardewModdingAPI.Framework
{
@@ -19,11 +20,14 @@ namespace StardewModdingAPI.Framework
IManifest Manifest { get; }
/// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary>
- ParsedModDataRecord DataRecord { get; }
+ ModDataRecordVersionedFields DataRecord { get; }
/// <summary>The metadata resolution status.</summary>
ModMetadataStatus Status { get; }
+ /// <summary>Indicates non-error issues with the mod.</summary>
+ ModWarning Warnings { get; }
+
/// <summary>The reason the metadata is invalid, if any.</summary>
string Error { get; }
@@ -42,6 +46,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the mod is a content pack.</summary>
bool IsContentPack { get; }
+ /// <summary>The update-check metadata for this mod (if any).</summary>
+ ModEntryModel UpdateCheckData { get; }
+
/*********
** Public methods
@@ -52,6 +59,10 @@ namespace StardewModdingAPI.Framework
/// <returns>Return the instance for chaining.</returns>
IModMetadata SetStatus(ModMetadataStatus status, string error = null);
+ /// <summary>Set a warning flag for the mod.</summary>
+ /// <param name="warning">The warning to set.</param>
+ IModMetadata SetWarning(ModWarning warning);
+
/// <summary>Set the mod instance.</summary>
/// <param name="mod">The mod instance to set.</param>
IModMetadata SetMod(IMod mod);
@@ -64,5 +75,18 @@ namespace StardewModdingAPI.Framework
/// <summary>Set the mod-provided API instance.</summary>
/// <param name="api">The mod-provided API.</param>
IModMetadata SetApi(object api);
+
+ /// <summary>Set the update-check metadata for this mod.</summary>
+ /// <param name="data">The update-check metadata.</param>
+ IModMetadata SetUpdateData(ModEntryModel data);
+
+ /// <summary>Whether the mod manifest was loaded (regardless of whether the mod itself was loaded).</summary>
+ bool HasManifest();
+
+ /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary>
+ bool HasID();
+
+ /// <summary>Whether the mod has at least one update key set.</summary>
+ bool HasUpdateKeys();
}
}
diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
new file mode 100644
index 00000000..33557385
--- /dev/null
+++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
@@ -0,0 +1,162 @@
+using System.Collections.Generic;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+
+namespace StardewModdingAPI.Framework.Input
+{
+ /// <summary>An abstraction for manipulating controller state.</summary>
+ internal class GamePadStateBuilder
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The current button states.</summary>
+ private readonly IDictionary<SButton, ButtonState> ButtonStates;
+
+ /// <summary>The left trigger value.</summary>
+ private float LeftTrigger;
+
+ /// <summary>The right trigger value.</summary>
+ private float RightTrigger;
+
+ /// <summary>The left thumbstick position.</summary>
+ private Vector2 LeftStickPos;
+
+ /// <summary>The left thumbstick position.</summary>
+ private Vector2 RightStickPos;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="state">The initial controller state.</param>
+ public GamePadStateBuilder(GamePadState state)
+ {
+ this.ButtonStates = new Dictionary<SButton, ButtonState>
+ {
+ [SButton.DPadUp] = state.DPad.Up,
+ [SButton.DPadDown] = state.DPad.Down,
+ [SButton.DPadLeft] = state.DPad.Left,
+ [SButton.DPadRight] = state.DPad.Right,
+
+ [SButton.ControllerA] = state.Buttons.A,
+ [SButton.ControllerB] = state.Buttons.B,
+ [SButton.ControllerX] = state.Buttons.X,
+ [SButton.ControllerY] = state.Buttons.Y,
+ [SButton.LeftStick] = state.Buttons.LeftStick,
+ [SButton.RightStick] = state.Buttons.RightStick,
+ [SButton.LeftShoulder] = state.Buttons.LeftShoulder,
+ [SButton.RightShoulder] = state.Buttons.RightShoulder,
+ [SButton.ControllerBack] = state.Buttons.Back,
+ [SButton.ControllerStart] = state.Buttons.Start,
+ [SButton.BigButton] = state.Buttons.BigButton
+ };
+ this.LeftTrigger = state.Triggers.Left;
+ this.RightTrigger = state.Triggers.Right;
+ this.LeftStickPos = state.ThumbSticks.Left;
+ this.RightStickPos = state.ThumbSticks.Right;
+ }
+
+ /// <summary>Mark all matching buttons unpressed.</summary>
+ /// <param name="buttons">The buttons.</param>
+ public void SuppressButtons(IEnumerable<SButton> buttons)
+ {
+ foreach (SButton button in buttons)
+ this.SuppressButton(button);
+ }
+
+ /// <summary>Mark a button unpressed.</summary>
+ /// <param name="button">The button.</param>
+ public void SuppressButton(SButton button)
+ {
+ switch (button)
+ {
+ // left thumbstick
+ case SButton.LeftThumbstickUp:
+ if (this.LeftStickPos.Y > 0)
+ this.LeftStickPos.Y = 0;
+ break;
+ case SButton.LeftThumbstickDown:
+ if (this.LeftStickPos.Y < 0)
+ this.LeftStickPos.Y = 0;
+ break;
+ case SButton.LeftThumbstickLeft:
+ if (this.LeftStickPos.X < 0)
+ this.LeftStickPos.X = 0;
+ break;
+ case SButton.LeftThumbstickRight:
+ if (this.LeftStickPos.X > 0)
+ this.LeftStickPos.X = 0;
+ break;
+
+ // right thumbstick
+ case SButton.RightThumbstickUp:
+ if (this.RightStickPos.Y > 0)
+ this.RightStickPos.Y = 0;
+ break;
+ case SButton.RightThumbstickDown:
+ if (this.RightStickPos.Y < 0)
+ this.RightStickPos.Y = 0;
+ break;
+ case SButton.RightThumbstickLeft:
+ if (this.RightStickPos.X < 0)
+ this.RightStickPos.X = 0;
+ break;
+ case SButton.RightThumbstickRight:
+ if (this.RightStickPos.X > 0)
+ this.RightStickPos.X = 0;
+ break;
+
+ // triggers
+ case SButton.LeftTrigger:
+ this.LeftTrigger = 0;
+ break;
+ case SButton.RightTrigger:
+ this.RightTrigger = 0;
+ break;
+
+ // buttons
+ default:
+ if (this.ButtonStates.ContainsKey(button))
+ this.ButtonStates[button] = ButtonState.Released;
+ break;
+ }
+ }
+
+ /// <summary>Construct an equivalent gamepad state.</summary>
+ public GamePadState ToGamePadState()
+ {
+ return new GamePadState(
+ leftThumbStick: this.LeftStickPos,
+ rightThumbStick: this.RightStickPos,
+ leftTrigger: this.LeftTrigger,
+ rightTrigger: this.RightTrigger,
+ buttons: this.GetBitmask(this.GetPressedButtons()) // MonoDevelop requires one bitmask here; don't specify multiple values
+ );
+ }
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get all pressed buttons.</summary>
+ private IEnumerable<Buttons> GetPressedButtons()
+ {
+ foreach (var pair in this.ButtonStates)
+ {
+ if (pair.Value == ButtonState.Pressed && pair.Key.TryGetController(out Buttons button))
+ yield return button;
+ }
+ }
+
+ /// <summary>Get a bitmask representing the given buttons.</summary>
+ /// <param name="buttons">The buttons to represent.</param>
+ private Buttons GetBitmask(IEnumerable<Buttons> buttons)
+ {
+ Buttons flag = 0;
+ foreach (Buttons button in buttons)
+ flag |= button;
+ return flag;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Input/InputState.cs b/src/SMAPI/Framework/Input/InputState.cs
deleted file mode 100644
index 8b0108ae..00000000
--- a/src/SMAPI/Framework/Input/InputState.cs
+++ /dev/null
@@ -1,163 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using StardewValley;
-
-namespace StardewModdingAPI.Framework.Input
-{
- /// <summary>A summary of input changes during an update frame.</summary>
- internal class InputState
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The underlying controller state.</summary>
- public GamePadState ControllerState { get; }
-
- /// <summary>The underlying keyboard state.</summary>
- public KeyboardState KeyboardState { get; }
-
- /// <summary>The underlying mouse state.</summary>
- public MouseState MouseState { get; }
-
- /// <summary>The mouse position on the screen adjusted for the zoom level.</summary>
- public Point MousePosition { get; }
-
- /// <summary>The buttons which were pressed, held, or released.</summary>
- public IDictionary<SButton, InputStatus> ActiveButtons { get; } = new Dictionary<SButton, InputStatus>();
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an empty instance.</summary>
- public InputState() { }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="previousState">The previous input state.</param>
- /// <param name="controllerState">The current controller state.</param>
- /// <param name="keyboardState">The current keyboard state.</param>
- /// <param name="mouseState">The current mouse state.</param>
- public InputState(InputState previousState, GamePadState controllerState, KeyboardState keyboardState, MouseState mouseState)
- {
- // init properties
- this.ControllerState = controllerState;
- this.KeyboardState = keyboardState;
- this.MouseState = mouseState;
- this.MousePosition = new Point((int)(mouseState.X * (1.0 / Game1.options.zoomLevel)), (int)(mouseState.Y * (1.0 / Game1.options.zoomLevel))); // derived from Game1::getMouseX
-
- // get button states
- SButton[] down = InputState.GetPressedButtons(keyboardState, mouseState, controllerState).ToArray();
- foreach (SButton button in down)
- this.ActiveButtons[button] = this.GetStatus(previousState.GetStatus(button), isDown: true);
- foreach (KeyValuePair<SButton, InputStatus> prev in previousState.ActiveButtons)
- {
- if (prev.Value.IsDown() && !this.ActiveButtons.ContainsKey(prev.Key))
- this.ActiveButtons[prev.Key] = InputStatus.Released;
- }
- }
-
- /// <summary>Get the status of a button.</summary>
- /// <param name="button">The button to check.</param>
- public InputStatus GetStatus(SButton button)
- {
- return this.ActiveButtons.TryGetValue(button, out InputStatus status) ? status : InputStatus.None;
- }
-
- /// <summary>Get whether a given button was pressed or held.</summary>
- /// <param name="button">The button to check.</param>
- public bool IsDown(SButton button)
- {
- return this.GetStatus(button).IsDown();
- }
-
- /// <summary>Get the current input state.</summary>
- /// <param name="previousState">The previous input state.</param>
- public static InputState GetState(InputState previousState)
- {
- GamePadState controllerState = GamePad.GetState(PlayerIndex.One);
- KeyboardState keyboardState = Keyboard.GetState();
- MouseState mouseState = Mouse.GetState();
-
- return new InputState(previousState, controllerState, keyboardState, mouseState);
- }
-
- /*********
- ** Private methods
- *********/
- /// <summary>Get the status of a button.</summary>
- /// <param name="oldStatus">The previous button status.</param>
- /// <param name="isDown">Whether the button is currently down.</param>
- public InputStatus GetStatus(InputStatus oldStatus, bool isDown)
- {
- if (isDown && oldStatus.IsDown())
- return InputStatus.Held;
- if (isDown)
- return InputStatus.Pressed;
- return InputStatus.Released;
- }
-
- /// <summary>Get the buttons pressed in the given stats.</summary>
- /// <param name="keyboard">The keyboard state.</param>
- /// <param name="mouse">The mouse state.</param>
- /// <param name="controller">The controller state.</param>
- private static IEnumerable<SButton> GetPressedButtons(KeyboardState keyboard, MouseState mouse, GamePadState controller)
- {
- // keyboard
- foreach (Keys key in keyboard.GetPressedKeys())
- yield return key.ToSButton();
-
- // mouse
- if (mouse.LeftButton == ButtonState.Pressed)
- yield return SButton.MouseLeft;
- if (mouse.RightButton == ButtonState.Pressed)
- yield return SButton.MouseRight;
- if (mouse.MiddleButton == ButtonState.Pressed)
- yield return SButton.MouseMiddle;
- if (mouse.XButton1 == ButtonState.Pressed)
- yield return SButton.MouseX1;
- if (mouse.XButton2 == ButtonState.Pressed)
- yield return SButton.MouseX2;
-
- // controller
- if (controller.IsConnected)
- {
- if (controller.Buttons.A == ButtonState.Pressed)
- yield return SButton.ControllerA;
- if (controller.Buttons.B == ButtonState.Pressed)
- yield return SButton.ControllerB;
- if (controller.Buttons.Back == ButtonState.Pressed)
- yield return SButton.ControllerBack;
- if (controller.Buttons.BigButton == ButtonState.Pressed)
- yield return SButton.BigButton;
- if (controller.Buttons.LeftShoulder == ButtonState.Pressed)
- yield return SButton.LeftShoulder;
- if (controller.Buttons.LeftStick == ButtonState.Pressed)
- yield return SButton.LeftStick;
- if (controller.Buttons.RightShoulder == ButtonState.Pressed)
- yield return SButton.RightShoulder;
- if (controller.Buttons.RightStick == ButtonState.Pressed)
- yield return SButton.RightStick;
- if (controller.Buttons.Start == ButtonState.Pressed)
- yield return SButton.ControllerStart;
- if (controller.Buttons.X == ButtonState.Pressed)
- yield return SButton.ControllerX;
- if (controller.Buttons.Y == ButtonState.Pressed)
- yield return SButton.ControllerY;
- if (controller.DPad.Up == ButtonState.Pressed)
- yield return SButton.DPadUp;
- if (controller.DPad.Down == ButtonState.Pressed)
- yield return SButton.DPadDown;
- if (controller.DPad.Left == ButtonState.Pressed)
- yield return SButton.DPadLeft;
- if (controller.DPad.Right == ButtonState.Pressed)
- yield return SButton.DPadRight;
- if (controller.Triggers.Left > 0.2f)
- yield return SButton.LeftTrigger;
- if (controller.Triggers.Right > 0.2f)
- yield return SButton.RightTrigger;
- }
- }
- }
-}
diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs
new file mode 100644
index 00000000..0228db0d
--- /dev/null
+++ b/src/SMAPI/Framework/Input/SInputState.cs
@@ -0,0 +1,382 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using StardewValley;
+
+#pragma warning disable 809 // obsolete override of non-obsolete method (this is deliberate)
+namespace StardewModdingAPI.Framework.Input
+{
+ /// <summary>Manages the game's input state.</summary>
+ internal sealed class SInputState : InputState
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The maximum amount of direction to ignore for the left thumbstick.</summary>
+ private const float LeftThumbstickDeadZone = 0.2f;
+
+ /// <summary>The cursor position on the screen adjusted for the zoom level.</summary>
+ private CursorPosition CursorPositionImpl;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The controller state as of the last update.</summary>
+ public GamePadState RealController { get; private set; }
+
+ /// <summary>The keyboard state as of the last update.</summary>
+ public KeyboardState RealKeyboard { get; private set; }
+
+ /// <summary>The mouse state as of the last update.</summary>
+ public MouseState RealMouse { get; private set; }
+
+ /// <summary>A derivative of <see cref="RealController"/> which suppresses the buttons in <see cref="SuppressButtons"/>.</summary>
+ public GamePadState SuppressedController { get; private set; }
+
+ /// <summary>A derivative of <see cref="RealKeyboard"/> which suppresses the buttons in <see cref="SuppressButtons"/>.</summary>
+ public KeyboardState SuppressedKeyboard { get; private set; }
+
+ /// <summary>A derivative of <see cref="RealMouse"/> which suppresses the buttons in <see cref="SuppressButtons"/>.</summary>
+ public MouseState SuppressedMouse { get; private set; }
+
+ /// <summary>The cursor position on the screen adjusted for the zoom level.</summary>
+ public ICursorPosition CursorPosition => this.CursorPositionImpl;
+
+ /// <summary>The buttons which were pressed, held, or released.</summary>
+ public IDictionary<SButton, InputStatus> ActiveButtons { get; private set; } = new Dictionary<SButton, InputStatus>();
+
+ /// <summary>The buttons to suppress when the game next handles input. Each button is suppressed until it's released.</summary>
+ public HashSet<SButton> SuppressButtons { get; } = new HashSet<SButton>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a copy of the current state.</summary>
+ public SInputState Clone()
+ {
+ return new SInputState
+ {
+ ActiveButtons = this.ActiveButtons,
+ RealController = this.RealController,
+ RealKeyboard = this.RealKeyboard,
+ RealMouse = this.RealMouse,
+ CursorPositionImpl = this.CursorPositionImpl
+ };
+ }
+
+ /// <summary>This method is called by the game, and does nothing since SMAPI will already have updated by that point.</summary>
+ [Obsolete("This method should only be called by the game itself.")]
+ public override void Update() { }
+
+ /// <summary>Update the current button statuses for the given tick.</summary>
+ public void TrueUpdate()
+ {
+ try
+ {
+ // get new states
+ GamePadState realController = GamePad.GetState(PlayerIndex.One);
+ KeyboardState realKeyboard = Keyboard.GetState();
+ MouseState realMouse = Mouse.GetState();
+ var activeButtons = this.DeriveStatuses(this.ActiveButtons, realKeyboard, realMouse, realController);
+ Vector2 cursorAbsolutePos = new Vector2(realMouse.X + Game1.viewport.X, realMouse.Y + Game1.viewport.Y);
+
+ // update real states
+ this.ActiveButtons = activeButtons;
+ this.RealController = realController;
+ this.RealKeyboard = realKeyboard;
+ this.RealMouse = realMouse;
+ if (this.CursorPositionImpl?.AbsolutePixels != cursorAbsolutePos)
+ this.CursorPositionImpl = this.GetCursorPosition(realMouse, cursorAbsolutePos);
+
+ // update suppressed states
+ this.SuppressButtons.RemoveWhere(p => !this.GetStatus(activeButtons, p).IsDown());
+ this.UpdateSuppression();
+ }
+ catch (InvalidOperationException)
+ {
+ // GetState() may crash for some players if window doesn't have focus but game1.IsActive == true
+ }
+ }
+
+ /// <summary>Apply input suppression to current input.</summary>
+ public void UpdateSuppression()
+ {
+ GamePadState suppressedController = this.RealController;
+ KeyboardState suppressedKeyboard = this.RealKeyboard;
+ MouseState suppressedMouse = this.RealMouse;
+
+ this.SuppressGivenStates(this.ActiveButtons, ref suppressedKeyboard, ref suppressedMouse, ref suppressedController);
+
+ this.SuppressedController = suppressedController;
+ this.SuppressedKeyboard = suppressedKeyboard;
+ this.SuppressedMouse = suppressedMouse;
+ }
+
+ /// <summary>Get the gamepad state visible to the game.</summary>
+ [Obsolete("This method should only be called by the game itself.")]
+ public override GamePadState GetGamePadState()
+ {
+ return this.ShouldSuppressNow()
+ ? this.SuppressedController
+ : this.RealController;
+ }
+
+ /// <summary>Get the keyboard state visible to the game.</summary>
+ [Obsolete("This method should only be called by the game itself.")]
+ public override KeyboardState GetKeyboardState()
+ {
+ return this.ShouldSuppressNow()
+ ? this.SuppressedKeyboard
+ : this.RealKeyboard;
+ }
+
+ /// <summary>Get the keyboard state visible to the game.</summary>
+ [Obsolete("This method should only be called by the game itself.")]
+ public override MouseState GetMouseState()
+ {
+ return this.ShouldSuppressNow()
+ ? this.SuppressedMouse
+ : this.RealMouse;
+ }
+
+ /// <summary>Get whether a given button was pressed or held.</summary>
+ /// <param name="button">The button to check.</param>
+ public bool IsDown(SButton button)
+ {
+ return this.GetStatus(this.ActiveButtons, button).IsDown();
+ }
+
+ /// <summary>Get whether any of the given buttons were pressed or held.</summary>
+ /// <param name="buttons">The buttons to check.</param>
+ public bool IsAnyDown(InputButton[] buttons)
+ {
+ return buttons.Any(button => this.IsDown(button.ToSButton()));
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the current cursor position.</summary>
+ /// <param name="mouseState">The current mouse state.</param>
+ /// <param name="absolutePixels">The absolute pixel position relative to the map.</param>
+ private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels)
+ {
+ Vector2 rawPixels = new Vector2(mouseState.X, mouseState.Y);
+ Vector2 screenPixels = rawPixels * new Vector2((float)1.0 / Game1.options.zoomLevel); // derived from Game1::getMouseX
+ Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize));
+ Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton
+ ? tile
+ : Game1.player.GetGrabTile();
+ return new CursorPosition(absolutePixels, screenPixels, tile, grabTile);
+ }
+
+ /// <summary>Whether input should be suppressed in the current context.</summary>
+ private bool ShouldSuppressNow()
+ {
+ return Game1.chatBox == null || !Game1.chatBox.isActive();
+ }
+
+ /// <summary>Apply input suppression to the given input states.</summary>
+ /// <param name="activeButtons">The current button states to check.</param>
+ /// <param name="keyboardState">The game's keyboard state for the current tick.</param>
+ /// <param name="mouseState">The game's mouse state for the current tick.</param>
+ /// <param name="gamePadState">The game's controller state for the current tick.</param>
+ private void SuppressGivenStates(IDictionary<SButton, InputStatus> activeButtons, ref KeyboardState keyboardState, ref MouseState mouseState, ref GamePadState gamePadState)
+ {
+ if (this.SuppressButtons.Count == 0)
+ return;
+
+ // gather info
+ HashSet<Keys> suppressKeys = new HashSet<Keys>();
+ HashSet<SButton> suppressButtons = new HashSet<SButton>();
+ HashSet<SButton> suppressMouse = new HashSet<SButton>();
+ foreach (SButton button in this.SuppressButtons)
+ {
+ if (button == SButton.MouseLeft || button == SButton.MouseMiddle || button == SButton.MouseRight || button == SButton.MouseX1 || button == SButton.MouseX2)
+ suppressMouse.Add(button);
+ else if (button.TryGetKeyboard(out Keys key))
+ suppressKeys.Add(key);
+ else if (gamePadState.IsConnected && button.TryGetController(out Buttons _))
+ suppressButtons.Add(button);
+ }
+
+ // suppress keyboard keys
+ if (keyboardState.GetPressedKeys().Any() && suppressKeys.Any())
+ keyboardState = new KeyboardState(keyboardState.GetPressedKeys().Except(suppressKeys).ToArray());
+
+ // suppress controller keys
+ if (gamePadState.IsConnected && suppressButtons.Any())
+ {
+ GamePadStateBuilder builder = new GamePadStateBuilder(gamePadState);
+ builder.SuppressButtons(suppressButtons);
+ gamePadState = builder.ToGamePadState();
+ }
+
+ // suppress mouse buttons
+ if (suppressMouse.Any())
+ {
+ mouseState = new MouseState(
+ x: mouseState.X,
+ y: mouseState.Y,
+ scrollWheel: mouseState.ScrollWheelValue,
+ leftButton: suppressMouse.Contains(SButton.MouseLeft) ? ButtonState.Released : mouseState.LeftButton,
+ middleButton: suppressMouse.Contains(SButton.MouseMiddle) ? ButtonState.Released : mouseState.MiddleButton,
+ rightButton: suppressMouse.Contains(SButton.MouseRight) ? ButtonState.Released : mouseState.RightButton,
+ xButton1: suppressMouse.Contains(SButton.MouseX1) ? ButtonState.Released : mouseState.XButton1,
+ xButton2: suppressMouse.Contains(SButton.MouseX2) ? ButtonState.Released : mouseState.XButton2
+ );
+ }
+ }
+
+ /// <summary>Get the status of all pressed or released buttons relative to their previous status.</summary>
+ /// <param name="previousStatuses">The previous button statuses.</param>
+ /// <param name="keyboard">The keyboard state.</param>
+ /// <param name="mouse">The mouse state.</param>
+ /// <param name="controller">The controller state.</param>
+ private IDictionary<SButton, InputStatus> DeriveStatuses(IDictionary<SButton, InputStatus> previousStatuses, KeyboardState keyboard, MouseState mouse, GamePadState controller)
+ {
+ IDictionary<SButton, InputStatus> activeButtons = new Dictionary<SButton, InputStatus>();
+
+ // handle pressed keys
+ SButton[] down = this.GetPressedButtons(keyboard, mouse, controller).ToArray();
+ foreach (SButton button in down)
+ activeButtons[button] = this.DeriveStatus(this.GetStatus(previousStatuses, button), isDown: true);
+
+ // handle released keys
+ foreach (KeyValuePair<SButton, InputStatus> prev in previousStatuses)
+ {
+ if (prev.Value.IsDown() && !activeButtons.ContainsKey(prev.Key))
+ activeButtons[prev.Key] = InputStatus.Released;
+ }
+
+ return activeButtons;
+ }
+
+ /// <summary>Get the status of a button relative to its previous status.</summary>
+ /// <param name="oldStatus">The previous button status.</param>
+ /// <param name="isDown">Whether the button is currently down.</param>
+ private InputStatus DeriveStatus(InputStatus oldStatus, bool isDown)
+ {
+ if (isDown && oldStatus.IsDown())
+ return InputStatus.Held;
+ if (isDown)
+ return InputStatus.Pressed;
+ return InputStatus.Released;
+ }
+
+ /// <summary>Get the status of a button.</summary>
+ /// <param name="activeButtons">The current button states to check.</param>
+ /// <param name="button">The button to check.</param>
+ private InputStatus GetStatus(IDictionary<SButton, InputStatus> activeButtons, SButton button)
+ {
+ return activeButtons.TryGetValue(button, out InputStatus status) ? status : InputStatus.None;
+ }
+
+ /// <summary>Get the buttons pressed in the given stats.</summary>
+ /// <param name="keyboard">The keyboard state.</param>
+ /// <param name="mouse">The mouse state.</param>
+ /// <param name="controller">The controller state.</param>
+ /// <remarks>Thumbstick direction logic derived from <see cref="ButtonCollection"/>.</remarks>
+ private IEnumerable<SButton> GetPressedButtons(KeyboardState keyboard, MouseState mouse, GamePadState controller)
+ {
+ // keyboard
+ foreach (Keys key in keyboard.GetPressedKeys())
+ yield return key.ToSButton();
+
+ // mouse
+ if (mouse.LeftButton == ButtonState.Pressed)
+ yield return SButton.MouseLeft;
+ if (mouse.RightButton == ButtonState.Pressed)
+ yield return SButton.MouseRight;
+ if (mouse.MiddleButton == ButtonState.Pressed)
+ yield return SButton.MouseMiddle;
+ if (mouse.XButton1 == ButtonState.Pressed)
+ yield return SButton.MouseX1;
+ if (mouse.XButton2 == ButtonState.Pressed)
+ yield return SButton.MouseX2;
+
+ // controller
+ if (controller.IsConnected)
+ {
+ // main buttons
+ if (controller.Buttons.A == ButtonState.Pressed)
+ yield return SButton.ControllerA;
+ if (controller.Buttons.B == ButtonState.Pressed)
+ yield return SButton.ControllerB;
+ if (controller.Buttons.X == ButtonState.Pressed)
+ yield return SButton.ControllerX;
+ if (controller.Buttons.Y == ButtonState.Pressed)
+ yield return SButton.ControllerY;
+ if (controller.Buttons.LeftStick == ButtonState.Pressed)
+ yield return SButton.LeftStick;
+ if (controller.Buttons.RightStick == ButtonState.Pressed)
+ yield return SButton.RightStick;
+ if (controller.Buttons.Start == ButtonState.Pressed)
+ yield return SButton.ControllerStart;
+
+ // directional pad
+ if (controller.DPad.Up == ButtonState.Pressed)
+ yield return SButton.DPadUp;
+ if (controller.DPad.Down == ButtonState.Pressed)
+ yield return SButton.DPadDown;
+ if (controller.DPad.Left == ButtonState.Pressed)
+ yield return SButton.DPadLeft;
+ if (controller.DPad.Right == ButtonState.Pressed)
+ yield return SButton.DPadRight;
+
+ // secondary buttons
+ if (controller.Buttons.Back == ButtonState.Pressed)
+ yield return SButton.ControllerBack;
+ if (controller.Buttons.BigButton == ButtonState.Pressed)
+ yield return SButton.BigButton;
+
+ // shoulders
+ if (controller.Buttons.LeftShoulder == ButtonState.Pressed)
+ yield return SButton.LeftShoulder;
+ if (controller.Buttons.RightShoulder == ButtonState.Pressed)
+ yield return SButton.RightShoulder;
+
+ // triggers
+ if (controller.Triggers.Left > 0.2f)
+ yield return SButton.LeftTrigger;
+ if (controller.Triggers.Right > 0.2f)
+ yield return SButton.RightTrigger;
+
+ // left thumbstick direction
+ if (controller.ThumbSticks.Left.Y > SInputState.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickUp;
+ if (controller.ThumbSticks.Left.Y < -SInputState.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickDown;
+ if (controller.ThumbSticks.Left.X > SInputState.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickRight;
+ if (controller.ThumbSticks.Left.X < -SInputState.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickLeft;
+
+ // right thumbstick direction
+ if (this.IsRightThumbstickOutsideDeadZone(controller.ThumbSticks.Right))
+ {
+ if (controller.ThumbSticks.Right.Y > 0)
+ yield return SButton.RightThumbstickUp;
+ if (controller.ThumbSticks.Right.Y < 0)
+ yield return SButton.RightThumbstickDown;
+ if (controller.ThumbSticks.Right.X > 0)
+ yield return SButton.RightThumbstickRight;
+ if (controller.ThumbSticks.Right.X < 0)
+ yield return SButton.RightThumbstickLeft;
+ }
+ }
+ }
+
+ /// <summary>Get whether the right thumbstick should be considered outside the dead zone.</summary>
+ /// <param name="direction">The right thumbstick value.</param>
+ private bool IsRightThumbstickOutsideDeadZone(Vector2 direction)
+ {
+ return direction.Length() > 0.9f;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs
index bff4807c..ff3925fb 100644
--- a/src/SMAPI/Framework/InternalExtensions.cs
+++ b/src/SMAPI/Framework/InternalExtensions.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Xna.Framework.Graphics;
-using Newtonsoft.Json.Linq;
using StardewModdingAPI.Framework.Reflection;
using StardewValley;
@@ -91,20 +90,5 @@ namespace StardewModdingAPI.Framework
// get result
return reflection.GetField<bool>(Game1.spriteBatch, fieldName).GetValue();
}
-
- /****
- ** Json.NET
- ****/
- /// <summary>Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity.</summary>
- /// <typeparam name="T">The value type.</typeparam>
- /// <param name="obj">The JSON object to search.</param>
- /// <param name="fieldName">The field name.</param>
- public static T ValueIgnoreCase<T>(this JObject obj, string fieldName)
- {
- JToken token = obj.GetValue(fieldName, StringComparison.InvariantCultureIgnoreCase);
- return token != null
- ? token.Value<T>()
- : default(T);
- }
}
}
diff --git a/src/SMAPI/Framework/LegacyManifestVersion.cs b/src/SMAPI/Framework/LegacyManifestVersion.cs
deleted file mode 100644
index 454b9137..00000000
--- a/src/SMAPI/Framework/LegacyManifestVersion.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Newtonsoft.Json;
-
-namespace StardewModdingAPI.Framework
-{
- /// <summary>An implementation of <see cref="ISemanticVersion"/> that hamdles the legacy <see cref="IManifest"/> version format.</summary>
- internal class LegacyManifestVersion : SemanticVersion
- {
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="majorVersion">The major version incremented for major API changes.</param>
- /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
- /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
- /// <param name="build">An optional build tag.</param>
- [JsonConstructor]
- public LegacyManifestVersion(int majorVersion, int minorVersion, int patchVersion, string build = null)
- : base(
- majorVersion,
- minorVersion,
- patchVersion,
- build != "0" ? build : null // '0' from incorrect examples in old SMAPI documentation
- )
- { }
- }
-}
diff --git a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs
index b8f2c34e..c04bcd1a 100644
--- a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs
+++ b/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs
@@ -15,9 +15,6 @@ namespace StardewModdingAPI.Framework.Logging
/*********
** Accessors
*********/
- /// <summary>Whether the current console supports color formatting.</summary>
- public bool SupportsColor { get; }
-
/// <summary>The event raised when a message is written to the console directly.</summary>
public event Action<string> OnMessageIntercepted;
@@ -32,9 +29,6 @@ namespace StardewModdingAPI.Framework.Logging
this.Output = new InterceptingTextWriter(Console.Out);
this.Output.OnMessageIntercepted += line => this.OnMessageIntercepted?.Invoke(line);
Console.SetOut(this.Output);
-
- // test color support
- this.SupportsColor = this.TestColorSupport();
}
/// <summary>Get an exclusive lock and write to the console output without interception.</summary>
@@ -61,26 +55,5 @@ namespace StardewModdingAPI.Framework.Logging
Console.SetOut(this.Output.Out);
this.Output.Dispose();
}
-
-
- /*********
- ** private methods
- *********/
- /// <summary>Test whether the current console supports color formatting.</summary>
- private bool TestColorSupport()
- {
- try
- {
- this.ExclusiveWriteWithoutInterception(() =>
- {
- Console.ForegroundColor = Console.ForegroundColor;
- });
- return true;
- }
- catch (Exception)
- {
- return false; // Mono bug
- }
- }
}
}
diff --git a/src/SMAPI/Framework/ModData/ModDatabase.cs b/src/SMAPI/Framework/ModData/ModDatabase.cs
deleted file mode 100644
index 3fd68440..00000000
--- a/src/SMAPI/Framework/ModData/ModDatabase.cs
+++ /dev/null
@@ -1,206 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using Newtonsoft.Json;
-
-namespace StardewModdingAPI.Framework.ModData
-{
- /// <summary>Handles access to SMAPI's internal mod metadata list.</summary>
- internal class ModDatabase
- {
- /*********
- ** Properties
- *********/
- /// <summary>The underlying mod data records indexed by default display name.</summary>
- private readonly IDictionary<string, ModDataRecord> Records;
-
- /// <summary>Get an update URL for an update key (if valid).</summary>
- private readonly Func<string, string> GetUpdateUrl;
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Construct an empty instance.</summary>
- public ModDatabase()
- : this(new Dictionary<string, ModDataRecord>(), key => null) { }
-
- /// <summary>Construct an instance.</summary>
- /// <param name="records">The underlying mod data records indexed by default display name.</param>
- /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param>
- public ModDatabase(IDictionary<string, ModDataRecord> records, Func<string, string> getUpdateUrl)
- {
- this.Records = records;
- this.GetUpdateUrl = getUpdateUrl;
- }
-
- /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary>
- /// <param name="manifest">The manifest to match.</param>
- public ParsedModDataRecord GetParsed(IManifest manifest)
- {
- // get raw record
- if (!this.TryGetRaw(manifest, out string displayName, out ModDataRecord record))
- return null;
-
- // parse fields
- ParsedModDataRecord parsed = new ParsedModDataRecord { DisplayName = displayName, DataRecord = record };
- foreach (ModDataField field in record.GetFields().Where(field => field.IsMatch(manifest)))
- {
- switch (field.Key)
- {
- // update key
- case ModDataFieldKey.UpdateKey:
- parsed.UpdateKey = field.Value;
- break;
-
- // alternative URL
- case ModDataFieldKey.AlternativeUrl:
- parsed.AlternativeUrl = field.Value;
- break;
-
- // status
- case ModDataFieldKey.Status:
- parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true);
- parsed.StatusUpperVersion = field.UpperVersion;
- break;
-
- // status reason phrase
- case ModDataFieldKey.StatusReasonPhrase:
- parsed.StatusReasonPhrase = field.Value;
- break;
- }
- }
-
- return parsed;
- }
-
- /// <summary>Get the display name for a given mod ID (if available).</summary>
- /// <param name="id">The unique mod ID.</param>
- public string GetDisplayNameFor(string id)
- {
- return this.TryGetRaw(id, out string displayName, out ModDataRecord _)
- ? displayName
- : null;
- }
-
- /// <summary>Get the mod page URL for a mod (if available).</summary>
- /// <param name="id">The unique mod ID.</param>
- public string GetModPageUrlFor(string id)
- {
- // get raw record
- if (!this.TryGetRaw(id, out string _, out ModDataRecord record))
- return null;
-
- // get update key
- ModDataField updateKeyField = record.GetFields().FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey);
- if (updateKeyField == null)
- return null;
-
- // get update URL
- return this.GetUpdateUrl(updateKeyField.Value);
- }
-
-
- /*********
- ** Private models
- *********/
- /// <summary>Get a raw data record.</summary>
- /// <param name="id">The mod ID to match.</param>
- /// <param name="displayName">The mod's default display name.</param>
- /// <param name="record">The raw mod record.</param>
- private bool TryGetRaw(string id, out string displayName, out ModDataRecord record)
- {
- foreach (var entry in this.Records)
- {
- if (entry.Value.ID != null && entry.Value.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase))
- {
- displayName = entry.Key;
- record = entry.Value;
- return true;
- }
- }
-
- displayName = null;
- record = null;
- return false;
- }
- /// <summary>Get a raw data record.</summary>
- /// <param name="manifest">The mod manifest whose fields to match.</param>
- /// <param name="displayName">The mod's default display name.</param>
- /// <param name="record">The raw mod record.</param>
- private bool TryGetRaw(IManifest manifest, out string displayName, out ModDataRecord record)
- {
- if (manifest != null)
- {
- foreach (var entry in this.Records)
- {
- displayName = entry.Key;
- record = entry.Value;
-
- // try main ID
- if (record.ID != null && record.ID.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase))
- return true;
-
- // try former IDs
- if (record.FormerIDs != null)
- {
- foreach (string part in record.FormerIDs.Split('|'))
- {
- // packed field snapshot
- if (part.StartsWith("{"))
- {
- FieldSnapshot snapshot = JsonConvert.DeserializeObject<FieldSnapshot>(part);
- bool isMatch =
- (snapshot.ID == null || snapshot.ID.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase))
- && (snapshot.EntryDll == null || snapshot.EntryDll.Equals(manifest.EntryDll, StringComparison.InvariantCultureIgnoreCase))
- && (
- snapshot.Author == null
- || snapshot.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase)
- || (manifest.ExtraFields != null && manifest.ExtraFields.ContainsKey("Authour") && snapshot.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase))
- )
- && (snapshot.Name == null || snapshot.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase));
-
- if (isMatch)
- return true;
- }
-
- // plain ID
- else if (part.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase))
- return true;
- }
- }
- }
- }
-
- displayName = null;
- record = null;
- return false;
- }
-
-
- /*********
- ** Private models
- *********/
- /// <summary>A unique set of fields which identifies the mod.</summary>
- [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialisation.")]
- [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialisation.")]
- private class FieldSnapshot
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The unique mod ID (or <c>null</c> to ignore it).</summary>
- public string ID { get; set; }
-
- /// <summary>The entry DLL (or <c>null</c> to ignore it).</summary>
- public string EntryDll { get; set; }
-
- /// <summary>The mod name (or <c>null</c> to ignore it).</summary>
- public string Name { get; set; }
-
- /// <summary>The author name (or <c>null</c> to ignore it).</summary>
- public string Author { get; set; }
- }
- }
-}
diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
index c7d4c39e..671dc21e 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
@@ -7,8 +7,9 @@ using System.IO;
using System.Linq;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Exceptions;
-using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using xTile;
using xTile.Format;
@@ -23,10 +24,13 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Properties
*********/
/// <summary>SMAPI's core content logic.</summary>
- private readonly ContentCore ContentCore;
+ private readonly ContentCoordinator ContentCore;
- /// <summary>The content manager for this mod.</summary>
- private readonly ContentManagerShim ContentManager;
+ /// <summary>A content manager for this mod which manages files from the game's Content folder.</summary>
+ private readonly IContentManager GameContentManager;
+
+ /// <summary>A content manager for this mod which manages files from the mod's folder.</summary>
+ private readonly IContentManager ModContentManager;
/// <summary>The absolute path to the mod folder.</summary>
private readonly string ModFolderPath;
@@ -42,10 +46,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
** Accessors
*********/
/// <summary>The game's current locale code (like <c>pt-BR</c>).</summary>
- public string CurrentLocale => this.ContentCore.GetLocale();
+ public string CurrentLocale => this.GameContentManager.GetLocale();
/// <summary>The game's current locale as an enum value.</summary>
- public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.ContentCore.Language;
+ public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language;
/// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary>
internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>();
@@ -65,16 +69,16 @@ namespace StardewModdingAPI.Framework.ModHelpers
*********/
/// <summary>Construct an instance.</summary>
/// <param name="contentCore">SMAPI's core content logic.</param>
- /// <param name="contentManager">The content manager for this mod.</param>
/// <param name="modFolderPath">The absolute path to the mod folder.</param>
/// <param name="modID">The unique ID of the relevant mod.</param>
/// <param name="modName">The friendly mod name for use in errors.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
- public ContentHelper(ContentCore contentCore, ContentManagerShim contentManager, string modFolderPath, string modID, string modName, IMonitor monitor)
+ public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor)
: base(modID)
{
this.ContentCore = contentCore;
- this.ContentManager = contentManager;
+ this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content");
+ this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), rootDirectory: modFolderPath);
this.ModFolderPath = modFolderPath;
this.ModName = modName;
this.Monitor = monitor;
@@ -92,24 +96,22 @@ namespace StardewModdingAPI.Framework.ModHelpers
try
{
- this.AssertValidAssetKeyFormat(key);
+ this.AssertAndNormaliseAssetName(key);
switch (source)
{
case ContentSource.GameContent:
- return this.ContentManager.Load<T>(key);
+ return this.GameContentManager.Load<T>(key);
case ContentSource.ModFolder:
// get file
FileInfo file = this.GetModFile(key);
if (!file.Exists)
throw GetContentError($"there's no matching file at path '{file.FullName}'.");
-
- // get asset path
- string assetName = this.ContentCore.GetAssetNameFromFilePath(file.FullName);
+ string internalKey = this.GetInternalModAssetKey(file);
// try cache
- if (this.ContentCore.IsLoaded(assetName))
- return this.ContentManager.Load<T>(assetName);
+ if (this.ModContentManager.IsLoaded(internalKey))
+ return this.ModContentManager.Load<T>(internalKey);
// fix map tilesheets
if (file.Extension.ToLower() == ".tbin")
@@ -121,15 +123,15 @@ namespace StardewModdingAPI.Framework.ModHelpers
// fetch & cache
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
- this.FixCustomTilesheetPaths(map, key);
+ this.FixCustomTilesheetPaths(map, relativeMapPath: key);
// inject map
- this.ContentManager.Inject(assetName, map);
+ this.ModContentManager.Inject(internalKey, map);
return (T)(object)map;
}
// load through content manager
- return this.ContentManager.Load<T>(assetName);
+ return this.ModContentManager.Load<T>(internalKey);
default:
throw GetContentError($"unknown content source '{source}'.");
@@ -146,7 +148,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
[Pure]
public string NormaliseAssetName(string assetName)
{
- return this.ContentCore.NormaliseAssetName(assetName);
+ return this.ModContentManager.AssertAndNormaliseAssetName(assetName);
}
/// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary>
@@ -158,11 +160,11 @@ namespace StardewModdingAPI.Framework.ModHelpers
switch (source)
{
case ContentSource.GameContent:
- return this.ContentCore.NormaliseAssetName(key);
+ return this.GameContentManager.AssertAndNormaliseAssetName(key);
case ContentSource.ModFolder:
FileInfo file = this.GetModFile(key);
- return this.ContentCore.NormaliseAssetName(this.ContentCore.GetAssetNameFromFilePath(file.FullName));
+ return this.GetInternalModAssetKey(file);
default:
throw new NotSupportedException($"Unknown content source '{source}'.");
@@ -177,7 +179,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent);
this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace);
- return this.ContentCore.InvalidateCache(asset => asset.AssetNameEquals(actualKey));
+ return this.ContentCore.InvalidateCache(asset => asset.AssetNameEquals(actualKey)).Any();
}
/// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary>
@@ -186,7 +188,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
public bool InvalidateCache<T>()
{
this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace);
- return this.ContentCore.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type));
+ return this.ContentCore.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type)).Any();
}
/// <summary>Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary>
@@ -195,7 +197,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
public bool InvalidateCache(Func<IAssetInfo, bool> predicate)
{
this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace);
- return this.ContentCore.InvalidateCache(predicate);
+ return this.ContentCore.InvalidateCache(predicate).Any();
}
/*********
@@ -205,16 +207,24 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="key">The asset key to check.</param>
/// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception>
[SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
- private void AssertValidAssetKeyFormat(string key)
+ private void AssertAndNormaliseAssetName(string key)
{
- this.ContentCore.AssertValidAssetKeyFormat(key);
+ this.ModContentManager.AssertAndNormaliseAssetName(key);
if (Path.IsPathRooted(key))
throw new ArgumentException("The asset key must not be an absolute path.");
}
+ /// <summary>Get the internal key in the content cache for a mod asset.</summary>
+ /// <param name="modFile">The asset file.</param>
+ private string GetInternalModAssetKey(FileInfo modFile)
+ {
+ string relativePath = PathUtilities.GetRelativePath(this.ModFolderPath, modFile.FullName);
+ return Path.Combine(this.ModContentManager.Name, relativePath);
+ }
+
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
/// <param name="map">The map whose tilesheets to fix.</param>
- /// <param name="mapKey">The map asset key within the mod folder.</param>
+ /// <param name="relativeMapPath">The relative map path within the mod folder.</param>
/// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception>
/// <remarks>
/// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils
@@ -230,13 +240,13 @@ namespace StardewModdingAPI.Framework.ModHelpers
///
/// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference.
/// </remarks>
- private void FixCustomTilesheetPaths(Map map, string mapKey)
+ private void FixCustomTilesheetPaths(Map map, string relativeMapPath)
{
// get map info
if (!map.TileSheets.Any())
return;
- mapKey = this.ContentCore.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
- string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder
+ relativeMapPath = this.ModContentManager.AssertAndNormaliseAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
+ string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder
// fix tilesheets
foreach (TileSheet tilesheet in map.TileSheets)
@@ -251,7 +261,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
string seasonalImageSource = null;
if (Game1.currentSeason != null)
{
- string filename = Path.GetFileName(imageSource);
+ string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null.");
bool hasSeasonalPrefix =
filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase)
|| filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase)
@@ -341,7 +351,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private FileInfo GetModFile(string path)
{
// try exact match
- path = Path.Combine(this.ModFolderPath, this.ContentCore.NormalisePathSeparators(path));
+ path = Path.Combine(this.ModFolderPath, this.ModContentManager.NormalisePathSeparators(path));
FileInfo file = new FileInfo(path);
// try with default extension
@@ -360,7 +370,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private FileInfo GetContentFolderFile(string key)
{
// get file path
- string path = Path.Combine(this.ContentCore.FullRootDirectory, key);
+ string path = Path.Combine(this.GameContentManager.FullRootDirectory, key);
if (!path.EndsWith(".xnb"))
path += ".xnb";
diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
new file mode 100644
index 00000000..f4cd12b6
--- /dev/null
+++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
@@ -0,0 +1,54 @@
+using StardewModdingAPI.Framework.Input;
+
+namespace StardewModdingAPI.Framework.ModHelpers
+{
+ /// <summary>Provides an API for checking and changing input state.</summary>
+ internal class InputHelper : BaseHelper, IInputHelper
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Manages the game's input state.</summary>
+ private readonly SInputState InputState;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="inputState">Manages the game's input state.</param>
+ public InputHelper(string modID, SInputState inputState)
+ : base(modID)
+ {
+ this.InputState = inputState;
+ }
+
+ /// <summary>Get the current cursor position.</summary>
+ public ICursorPosition GetCursorPosition()
+ {
+ return this.InputState.CursorPosition;
+ }
+
+ /// <summary>Get whether a button is currently pressed.</summary>
+ /// <param name="button">The button.</param>
+ public bool IsDown(SButton button)
+ {
+ return this.InputState.IsDown(button);
+ }
+
+ /// <summary>Get whether a button is currently suppressed, so the game won't see it.</summary>
+ /// <param name="button">The button.</param>
+ public bool IsSuppressed(SButton button)
+ {
+ return this.InputState.SuppressButtons.Contains(button);
+ }
+
+ /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary>
+ /// <param name="button">The button to suppress.</param>
+ public void Suppress(SButton button)
+ {
+ this.InputState.SuppressButtons.Add(button);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
index b5758d21..d9498e83 100644
--- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
@@ -2,9 +2,11 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using StardewModdingAPI.Framework.Models;
-using StardewModdingAPI.Framework.Serialisation;
-using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.Input;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Framework.ModHelpers
{
@@ -33,9 +35,15 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>The full path to the mod's folder.</summary>
public string DirectoryPath { get; }
+ /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary>
+ public IModEvents Events { get; }
+
/// <summary>An API for loading content assets.</summary>
public IContentHelper Content { get; }
+ /// <summary>An API for checking and changing input state.</summary>
+ public IInputHelper Input { get; }
+
/// <summary>An API for accessing private game code.</summary>
public IReflectionHelper Reflection { get; }
@@ -45,6 +53,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>An API for managing console commands.</summary>
public ICommandHelper ConsoleCommands { get; }
+ /// <summary>Provides multiplayer utilities.</summary>
+ public IMultiplayerHelper Multiplayer { get; }
+
/// <summary>An API for reading translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> &lt; <c>pt.json</c> &lt; <c>default.json</c>).</summary>
public ITranslationHelper Translation { get; }
@@ -56,17 +67,20 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="modID">The mod's unique ID.</param>
/// <param name="modDirectory">The full path to the mod's folder.</param>
/// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param>
+ /// <param name="inputState">Manages the game's input state.</param>
+ /// <param name="events">Manages access to events raised by SMAPI.</param>
/// <param name="contentHelper">An API for loading content assets.</param>
/// <param name="commandHelper">An API for managing console commands.</param>
/// <param name="modRegistry">an API for fetching metadata about loaded mods.</param>
/// <param name="reflectionHelper">An API for accessing private game code.</param>
+ /// <param name="multiplayer">Provides multiplayer utilities.</param>
/// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param>
/// <param name="contentPacks">The content packs loaded for this mod.</param>
/// <param name="createContentPack">Create a transitional content pack.</param>
/// <param name="deprecationManager">Manages deprecation warnings.</param>
/// <exception cref="ArgumentNullException">An argument is null or empty.</exception>
/// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception>
- public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper, IEnumerable<IContentPack> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager)
+ public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, SInputState inputState, IModEvents events, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, IEnumerable<IContentPack> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager)
: base(modID)
{
// validate directory
@@ -79,13 +93,16 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.DirectoryPath = modDirectory;
this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper));
this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper));
+ this.Input = new InputHelper(modID, inputState);
this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry));
this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper));
this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper));
+ this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer));
this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper));
this.ContentPacks = contentPacks.ToArray();
this.CreateContentPack = createContentPack;
this.DeprecationManager = deprecationManager;
+ this.Events = events;
}
/****
@@ -152,26 +169,24 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.DeprecationManager.Warn($"{nameof(IModHelper)}.{nameof(IModHelper.CreateTransitionalContentPack)}", "2.5", DeprecationLevel.Notice);
// validate
- if(string.IsNullOrWhiteSpace(directoryPath))
+ if (string.IsNullOrWhiteSpace(directoryPath))
throw new ArgumentNullException(nameof(directoryPath));
- if(string.IsNullOrWhiteSpace(id))
+ if (string.IsNullOrWhiteSpace(id))
throw new ArgumentNullException(nameof(id));
- if(string.IsNullOrWhiteSpace(name))
+ if (string.IsNullOrWhiteSpace(name))
throw new ArgumentNullException(nameof(name));
- if(!Directory.Exists(directoryPath))
+ if (!Directory.Exists(directoryPath))
throw new ArgumentException($"Can't create content pack for directory path '{directoryPath}' because no such directory exists.");
// create manifest
- IManifest manifest = new Manifest
- {
- Name = name,
- Author = author,
- Description = description,
- Version = version,
- UniqueID = id,
- UpdateKeys = new string[0],
- ContentPackFor = new ManifestContentPackFor { UniqueID = this.ModID }
- };
+ IManifest manifest = new Manifest(
+ uniqueID: id,
+ name: name,
+ author: author,
+ description: description,
+ version: version,
+ contentPackFor: this.ModID
+ );
// create content pack
return this.CreateContentPack(directoryPath, manifest);
diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
index e579a830..008a80f5 100644
--- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs
@@ -82,12 +82,12 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
if (!typeof(TInterface).IsInterface)
{
- this.Monitor.Log("Tried to map a mod-provided API to a class; must be a public interface.", LogLevel.Error);
+ this.Monitor.Log($"Tried to map a mod-provided API to class '{typeof(TInterface).FullName}'; must be a public interface.", LogLevel.Error);
return null;
}
if (!typeof(TInterface).IsPublic)
{
- this.Monitor.Log("Tried to map a mod-provided API to a non-public interface; must be a public interface.", LogLevel.Error);
+ this.Monitor.Log($"Tried to map a mod-provided API to non-public interface '{typeof(TInterface).FullName}'; must be a public interface.", LogLevel.Error);
return null;
}
diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
new file mode 100644
index 00000000..c449a51b
--- /dev/null
+++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.ModHelpers
+{
+ /// <summary>Provides multiplayer utilities.</summary>
+ internal class MultiplayerHelper : BaseHelper, IMultiplayerHelper
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>SMAPI's core multiplayer utility.</summary>
+ private readonly SMultiplayer Multiplayer;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="multiplayer">SMAPI's core multiplayer utility.</param>
+ public MultiplayerHelper(string modID, SMultiplayer multiplayer)
+ : base(modID)
+ {
+ this.Multiplayer = multiplayer;
+ }
+
+ /// <summary>Get the locations which are being actively synced from the host.</summary>
+ public IEnumerable<GameLocation> GetActiveLocations()
+ {
+ return this.Multiplayer.activeLocations();
+ }
+
+ /// <summary>Get a new multiplayer ID.</summary>
+ public long GetNewID()
+ {
+ return this.Multiplayer.getNewID();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
index e5bf47f6..648d6742 100644
--- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs
@@ -107,122 +107,6 @@ namespace StardewModdingAPI.Framework.ModHelpers
);
}
-#if !STARDEW_VALLEY_1_3
- /****
- ** Obsolete
- ****/
- /// <summary>Get a private instance field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="obj">The object which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns>
- [Obsolete]
- public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- return (IPrivateField<TValue>)this.GetField<TValue>(obj, name, required);
- }
-
- /// <summary>Get a private static field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="type">The type which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete]
- public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- return (IPrivateField<TValue>)this.GetField<TValue>(type, name, required);
- }
-
- /// <summary>Get a private instance property.</summary>
- /// <typeparam name="TValue">The property type.</typeparam>
- /// <param name="obj">The object which has the property.</param>
- /// <param name="name">The property name.</param>
- /// <param name="required">Whether to throw an exception if the private property is not found.</param>
- [Obsolete]
- public IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- return (IPrivateProperty<TValue>)this.GetProperty<TValue>(obj, name, required);
- }
-
- /// <summary>Get a private static property.</summary>
- /// <typeparam name="TValue">The property type.</typeparam>
- /// <param name="type">The type which has the property.</param>
- /// <param name="name">The property name.</param>
- /// <param name="required">Whether to throw an exception if the private property is not found.</param>
- [Obsolete]
- public IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- return (IPrivateProperty<TValue>)this.GetProperty<TValue>(type, name, required);
- }
-
- /// <summary>Get the value of a private instance field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="obj">The object which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- /// <returns>Returns the field value, or the default value for <typeparamref name="TValue"/> if the field wasn't found and <paramref name="required"/> is false.</returns>
- /// <remarks>
- /// This is a shortcut for <see cref="GetPrivateField{TValue}(object,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.
- /// When <paramref name="required" /> is false, this will return the default value if reflection fails. If you need to check whether the field exists, use <see cref="GetPrivateField{TValue}(object,string,bool)" /> instead.
- /// </remarks>
- [Obsolete]
- public TValue GetPrivateValue<TValue>(object obj, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- IPrivateField<TValue> field = (IPrivateField<TValue>)this.GetField<TValue>(obj, name, required);
- return field != null
- ? field.GetValue()
- : default(TValue);
- }
-
- /// <summary>Get the value of a private static field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="type">The type which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- /// <returns>Returns the field value, or the default value for <typeparamref name="TValue"/> if the field wasn't found and <paramref name="required"/> is false.</returns>
- /// <remarks>
- /// This is a shortcut for <see cref="GetPrivateField{TValue}(Type,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.
- /// When <paramref name="required" /> is false, this will return the default value if reflection fails. If you need to check whether the field exists, use <see cref="GetPrivateField{TValue}(Type,string,bool)" /> instead.
- /// </remarks>
- [Obsolete]
- public TValue GetPrivateValue<TValue>(Type type, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- IPrivateField<TValue> field = (IPrivateField<TValue>)this.GetField<TValue>(type, name, required);
- return field != null
- ? field.GetValue()
- : default(TValue);
- }
-
- /// <summary>Get a private instance method.</summary>
- /// <param name="obj">The object which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete]
- public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- return (IPrivateMethod)this.GetMethod(obj, name, required);
- }
-
- /// <summary>Get a private static method.</summary>
- /// <param name="type">The type which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete]
- public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true)
- {
- this.DeprecationManager.Warn($"{nameof(IReflectionHelper)}.GetPrivate*", "2.3", DeprecationLevel.Notice);
- return (IPrivateMethod)this.GetMethod(type, name, required);
- }
-#endif
-
/*********
** Private methods
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
index d85a9a28..91c9e192 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
@@ -10,7 +10,7 @@ namespace StardewModdingAPI.Framework.ModLoading
** Properties
*********/
/// <summary>The known assemblies.</summary>
- private readonly IDictionary<string, AssemblyDefinition> Loaded = new Dictionary<string, AssemblyDefinition>();
+ private readonly IDictionary<string, AssemblyDefinition> Lookup = new Dictionary<string, AssemblyDefinition>();
/*********
@@ -22,8 +22,9 @@ namespace StardewModdingAPI.Framework.ModLoading
{
foreach (AssemblyDefinition assembly in assemblies)
{
- this.Loaded[assembly.Name.Name] = assembly;
- this.Loaded[assembly.Name.FullName] = assembly;
+ this.RegisterAssembly(assembly);
+ this.Lookup[assembly.Name.Name] = assembly;
+ this.Lookup[assembly.Name.FullName] = assembly;
}
}
@@ -36,15 +37,6 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="parameters">The assembly reader parameters.</param>
public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) => this.ResolveName(name.Name) ?? base.Resolve(name, parameters);
- /// <summary>Resolve an assembly reference.</summary>
- /// <param name="fullName">The assembly full name (including version, etc).</param>
- public override AssemblyDefinition Resolve(string fullName) => this.ResolveName(fullName) ?? base.Resolve(fullName);
-
- /// <summary>Resolve an assembly reference.</summary>
- /// <param name="fullName">The assembly full name (including version, etc).</param>
- /// <param name="parameters">The assembly reader parameters.</param>
- public override AssemblyDefinition Resolve(string fullName, ReaderParameters parameters) => this.ResolveName(fullName) ?? base.Resolve(fullName, parameters);
-
/*********
** Private methods
@@ -53,8 +45,8 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="name">The assembly's short or full name.</param>
private AssemblyDefinition ResolveName(string name)
{
- return this.Loaded.ContainsKey(name)
- ? this.Loaded[name]
+ return this.Lookup.TryGetValue(name, out AssemblyDefinition match)
+ ? match
: null;
}
}
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index a60f63da..37b1a378 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -6,12 +6,13 @@ using System.Reflection;
using Mono.Cecil;
using Mono.Cecil.Cil;
using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Internal;
using StardewModdingAPI.Metadata;
namespace StardewModdingAPI.Framework.ModLoading
{
/// <summary>Preprocesses and loads mod assemblies.</summary>
- internal class AssemblyLoader
+ internal class AssemblyLoader : IDisposable
{
/*********
** Properties
@@ -19,9 +20,6 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
- /// <summary>Whether to enable developer mode logging.</summary>
- private readonly bool IsDeveloperMode;
-
/// <summary>Metadata for mapping assemblies to the current platform.</summary>
private readonly PlatformAssemblyMap AssemblyMap;
@@ -31,6 +29,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>A minimal assembly definition resolver which resolves references to known loaded assemblies.</summary>
private readonly AssemblyDefinitionResolver AssemblyDefinitionResolver;
+ /// <summary>The objects to dispose as part of this instance.</summary>
+ private readonly HashSet<IDisposable> Disposables = new HashSet<IDisposable>();
+
/*********
** Public methods
@@ -38,13 +39,12 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Construct an instance.</summary>
/// <param name="targetPlatform">The current game platform.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
- /// <param name="isDeveloperMode">Whether to enable developer mode logging.</param>
- public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool isDeveloperMode)
+ public AssemblyLoader(Platform targetPlatform, IMonitor monitor)
{
this.Monitor = monitor;
- this.IsDeveloperMode = isDeveloperMode;
- this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform);
- this.AssemblyDefinitionResolver = new AssemblyDefinitionResolver();
+ this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform));
+ this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver());
+ this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.ExecutionPath);
// generate type => assembly lookup for types which should be rewritten
this.TypeAssemblies = new Dictionary<string, Assembly>();
@@ -98,13 +98,26 @@ namespace StardewModdingAPI.Framework.ModLoading
continue;
// rewrite assembly
- bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " ");
+ bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " ");
+
+ // detect broken assembly reference
+ foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences)
+ {
+ if (!reference.Name.StartsWith("System.") && !this.IsAssemblyLoaded(reference))
+ {
+ this.Monitor.LogOnce(loggedMessages, $" Broken code in {assembly.File.Name}: reference to missing assembly '{reference.FullName}'.");
+ if (!assumeCompatible)
+ throw new IncompatibleInstructionException($"assembly reference to {reference.FullName}", $"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}.");
+ mod.SetWarning(ModWarning.BrokenCodeLoaded);
+ break;
+ }
+ }
// load assembly
if (changed)
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace);
using (MemoryStream outStream = new MemoryStream())
{
assembly.Definition.Write(outStream);
@@ -115,7 +128,7 @@ namespace StardewModdingAPI.Framework.ModLoading
else
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName);
}
@@ -127,6 +140,20 @@ namespace StardewModdingAPI.Framework.ModLoading
return lastAssembly;
}
+ /// <summary>Get whether an assembly is loaded.</summary>
+ /// <param name="reference">The assembly name reference.</param>
+ public bool IsAssemblyLoaded(AssemblyNameReference reference)
+ {
+ try
+ {
+ return this.AssemblyDefinitionResolver.Resolve(reference) != null;
+ }
+ catch (AssemblyResolutionException)
+ {
+ return false;
+ }
+ }
+
/// <summary>Resolve an assembly by its name.</summary>
/// <param name="name">The assembly name.</param>
/// <remarks>
@@ -135,7 +162,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// assemblies (especially with Mono). Since this is meant to be called on <see cref="AppDomain.AssemblyResolve"/>,
/// the implicit assumption is that loading the exact assembly failed.
/// </remarks>
- public Assembly ResolveAssembly(string name)
+ public static Assembly ResolveAssembly(string name)
{
string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
@@ -143,10 +170,26 @@ namespace StardewModdingAPI.Framework.ModLoading
.FirstOrDefault(p => p.GetName().Name == shortName);
}
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ foreach (IDisposable instance in this.Disposables)
+ instance.Dispose();
+ }
+
/*********
** Private methods
*********/
+ /// <summary>Track an object for disposal as part of the assembly loader.</summary>
+ /// <typeparam name="T">The instance type.</typeparam>
+ /// <param name="instance">The disposable instance.</param>
+ private T TrackForDisposal<T>(T instance) where T : IDisposable
+ {
+ this.Disposables.Add(instance);
+ return instance;
+ }
+
/****
** Assembly parsing
****/
@@ -165,9 +208,8 @@ namespace StardewModdingAPI.Framework.ModLoading
// read assembly
byte[] assemblyBytes = File.ReadAllBytes(file.FullName);
- AssemblyDefinition assembly;
- using (Stream readStream = new MemoryStream(assemblyBytes))
- assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver });
+ Stream readStream = this.TrackForDisposal(new MemoryStream(assemblyBytes));
+ AssemblyDefinition assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true }));
// skip if already visited
if (visitedAssemblyNames.Contains(assembly.Name.Name))
@@ -284,33 +326,27 @@ namespace StardewModdingAPI.Framework.ModLoading
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Broken code in {filename}: {handler.NounPhrase}.");
if (!assumeCompatible)
throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Found broken code ({handler.NounPhrase}) while loading assembly {filename}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.BrokenCodeLoaded);
break;
case InstructionHandleResult.DetectedGamePatch:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected game patcher ({handler.NounPhrase}) in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} patches the game, which may impact game stability. If you encounter problems, try removing this mod first.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.PatchesGame);
break;
case InstructionHandleResult.DetectedSaveSerialiser:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serialiser change ({handler.NounPhrase}) in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} seems to change the save serialiser. It may change your saves in such a way that they won't work without this mod in the future.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.ChangesSaveSerialiser);
break;
case InstructionHandleResult.DetectedUnvalidatedUpdateTick:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected reference to {handler.NounPhrase} in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses a specialised SMAPI event that may crash the game or corrupt your save file. If you encounter problems, try removing this mod first.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.UsesUnvalidatedUpdateTick);
break;
case InstructionHandleResult.DetectedDynamic:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected 'dynamic' keyword ({handler.NounPhrase}) in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses the 'dynamic' keyword, which isn't compatible with Stardew Valley on Linux or Mac.",
-#if SMAPI_FOR_WINDOWS
- this.IsDeveloperMode ? LogLevel.Warn : LogLevel.Debug
-#else
- LogLevel.Warn
-#endif
- );
+ mod.SetWarning(ModWarning.UsesDynamic);
break;
case InstructionHandleResult.None:
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
index b5e45742..cf5a3175 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
-using System.Text.RegularExpressions;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -16,9 +15,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <summary>The assembly names to which to heuristically detect broken references.</summary>
private readonly HashSet<string> ValidateReferencesToAssemblies;
- /// <summary>A pattern matching type name substrings to strip for display.</summary>
- private readonly Regex StripTypeNamePattern = new Regex(@"`\d+(?=<)", RegexOptions.Compiled);
-
/*********
** Accessors
@@ -59,21 +55,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType))
{
- // can't compare generic type parameters between definition and reference
- if (fieldRef.FieldType.IsGenericInstance || fieldRef.FieldType.IsGenericParameter)
- return InstructionHandleResult.None;
-
// get target field
FieldDefinition targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name);
if (targetField == null)
return InstructionHandleResult.None;
// validate return type
- string actualReturnTypeID = this.GetComparableTypeID(targetField.FieldType);
- string expectedReturnTypeID = this.GetComparableTypeID(fieldRef.FieldType);
- if (actualReturnTypeID != expectedReturnTypeID)
+ if (!RewriteHelper.LooksLikeSameType(fieldRef.FieldType, targetField.FieldType))
{
- this.NounPhrase = $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType, actualReturnTypeID)}, not {this.GetFriendlyTypeName(fieldRef.FieldType, expectedReturnTypeID)})";
+ this.NounPhrase = $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType)}, not {this.GetFriendlyTypeName(fieldRef.FieldType)})";
return InstructionHandleResult.NotCompatible;
}
}
@@ -82,10 +72,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference methodReference = RewriteHelper.AsMethodReference(instruction);
if (methodReference != null && this.ShouldValidate(methodReference.DeclaringType))
{
- // can't compare generic type parameters between definition and reference
- if (methodReference.ReturnType.IsGenericInstance || methodReference.ReturnType.IsGenericParameter)
- return InstructionHandleResult.None;
-
// get potential targets
MethodDefinition[] candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray();
if (candidateMethods == null || !candidateMethods.Any())
@@ -99,10 +85,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
return InstructionHandleResult.NotCompatible;
}
- string expectedReturnType = this.GetComparableTypeID(methodDef.ReturnType);
- if (candidateMethods.All(method => this.GetComparableTypeID(method.ReturnType) != expectedReturnType))
+ if (candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType)))
{
- this.NounPhrase = $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType, expectedReturnType)})";
+ this.NounPhrase = $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType)})";
return InstructionHandleResult.NotCompatible;
}
}
@@ -121,17 +106,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name);
}
- /// <summary>Get a unique string representation of a type.</summary>
- /// <param name="type">The type reference.</param>
- private string GetComparableTypeID(TypeReference type)
- {
- return this.StripTypeNamePattern.Replace(type.FullName, "");
- }
-
/// <summary>Get a shorter type name for display.</summary>
/// <param name="type">The type reference.</param>
- /// <param name="typeID">The comparable type ID from <see cref="GetComparableTypeID"/>.</param>
- private string GetFriendlyTypeName(TypeReference type, string typeID)
+ private string GetFriendlyTypeName(TypeReference type)
{
// most common built-in types
switch (type.FullName)
@@ -148,10 +125,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
foreach (string @namespace in new[] { "Microsoft.Xna.Framework", "Netcode", "System", "System.Collections.Generic" })
{
if (type.Namespace == @namespace)
- return typeID.Substring(@namespace.Length + 1);
+ return type.Name;
}
- return typeID;
+ return type.FullName;
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
index f5e33313..b95dd79c 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
@@ -67,12 +67,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && this.ShouldValidate(methodRef.DeclaringType) && !this.IsUnsupported(methodRef))
{
- MethodDefinition target = methodRef.DeclaringType.Resolve()?.Methods.FirstOrDefault(p => p.Name == methodRef.Name);
+ MethodDefinition target = methodRef.Resolve();
if (target == null)
{
- this.NounPhrase = this.IsProperty(methodRef)
- ? $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)"
- : $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)";
+ if (this.IsProperty(methodRef))
+ this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)";
+ else if (methodRef.Name == ".ctor")
+ this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no matching constructor)";
+ else
+ this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)";
return InstructionHandleResult.NotCompatible;
}
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
index 45349def..79045241 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
@@ -1,3 +1,4 @@
+using System;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -16,6 +17,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <summary>The result to return for matching instructions.</summary>
private readonly InstructionHandleResult Result;
+ /// <summary>A lambda which overrides a matched type.</summary>
+ protected readonly Func<TypeReference, bool> ShouldIgnore;
+
/*********
** Accessors
@@ -30,11 +34,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <summary>Construct an instance.</summary>
/// <param name="fullTypeName">The full type name to match.</param>
/// <param name="result">The result to return for matching instructions.</param>
- public TypeFinder(string fullTypeName, InstructionHandleResult result)
+ /// <param name="shouldIgnore">A lambda which overrides a matched type.</param>
+ public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null)
{
this.FullTypeName = fullTypeName;
this.Result = result;
this.NounPhrase = $"{fullTypeName} type";
+ this.ShouldIgnore = shouldIgnore ?? (p => false);
}
/// <summary>Perform the predefined logic for a method if applicable.</summary>
@@ -113,7 +119,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
protected bool IsMatch(TypeReference type)
{
// root type
- if (type.FullName == this.FullTypeName)
+ if (type.FullName == this.FullTypeName && !this.ShouldIgnore(type))
return true;
// generic arguments
diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
index 1a0f9994..585debb4 100644
--- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs
+++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
@@ -1,5 +1,7 @@
using System;
-using StardewModdingAPI.Framework.ModData;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
+using StardewModdingAPI.Toolkit.Framework.ModData;
namespace StardewModdingAPI.Framework.ModLoading
{
@@ -19,11 +21,14 @@ namespace StardewModdingAPI.Framework.ModLoading
public IManifest Manifest { get; }
/// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary>
- public ParsedModDataRecord DataRecord { get; }
+ public ModDataRecordVersionedFields DataRecord { get; }
/// <summary>The metadata resolution status.</summary>
public ModMetadataStatus Status { get; private set; }
+ /// <summary>Indicates non-error issues with the mod.</summary>
+ public ModWarning Warnings { get; private set; }
+
/// <summary>The reason the metadata is invalid, if any.</summary>
public string Error { get; private set; }
@@ -39,6 +44,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The mod-provided API (if any).</summary>
public object Api { get; private set; }
+ /// <summary>The update-check metadata for this mod (if any).</summary>
+ public ModEntryModel UpdateCheckData { get; private set; }
+
/// <summary>Whether the mod is a content pack.</summary>
public bool IsContentPack => this.Manifest?.ContentPackFor != null;
@@ -51,7 +59,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="directoryPath">The mod's full directory path.</param>
/// <param name="manifest">The mod manifest.</param>
/// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param>
- public ModMetadata(string displayName, string directoryPath, IManifest manifest, ParsedModDataRecord dataRecord)
+ public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord)
{
this.DisplayName = displayName;
this.DirectoryPath = directoryPath;
@@ -70,6 +78,14 @@ namespace StardewModdingAPI.Framework.ModLoading
return this;
}
+ /// <summary>Set a warning flag for the mod.</summary>
+ /// <param name="warning">The warning to set.</param>
+ public IModMetadata SetWarning(ModWarning warning)
+ {
+ this.Warnings |= warning;
+ return this;
+ }
+
/// <summary>Set the mod instance.</summary>
/// <param name="mod">The mod instance to set.</param>
public IModMetadata SetMod(IMod mod)
@@ -102,5 +118,36 @@ namespace StardewModdingAPI.Framework.ModLoading
this.Api = api;
return this;
}
+
+ /// <summary>Set the update-check metadata for this mod.</summary>
+ /// <param name="data">The update-check metadata.</param>
+ public IModMetadata SetUpdateData(ModEntryModel data)
+ {
+ this.UpdateCheckData = data;
+ return this;
+ }
+
+ /// <summary>Whether the mod manifest was loaded (regardless of whether the mod itself was loaded).</summary>
+ public bool HasManifest()
+ {
+ return this.Manifest != null;
+ }
+
+ /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary>
+ public bool HasID()
+ {
+ return
+ this.HasManifest()
+ && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID);
+ }
+
+ /// <summary>Whether the mod has at least one update key set.</summary>
+ public bool HasUpdateKeys()
+ {
+ return
+ this.HasManifest()
+ && this.Manifest.UpdateKeys != null
+ && this.Manifest.UpdateKeys.Any(key => !string.IsNullOrWhiteSpace(key));
+ }
}
}
diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs
index a9896278..9ac95fd4 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -2,11 +2,12 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using StardewModdingAPI.Framework.Exceptions;
-using StardewModdingAPI.Framework.ModData;
-using StardewModdingAPI.Framework.Models;
-using StardewModdingAPI.Framework.Serialisation;
-using StardewModdingAPI.Framework.Utilities;
+using System.Text.RegularExpressions;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+using StardewModdingAPI.Toolkit.Framework.ModScanning;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Framework.ModLoading
{
@@ -17,46 +18,25 @@ namespace StardewModdingAPI.Framework.ModLoading
** Public methods
*********/
/// <summary>Get manifest metadata for each folder in the given root path.</summary>
+ /// <param name="toolkit">The mod toolkit.</param>
/// <param name="rootPath">The root path to search for mods.</param>
- /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
/// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
/// <returns>Returns the manifests by relative folder.</returns>
- public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, ModDatabase modDatabase)
+ public IEnumerable<IModMetadata> ReadManifests(ModToolkit toolkit, string rootPath, ModDatabase modDatabase)
{
- foreach (DirectoryInfo modDir in this.GetModFolders(rootPath))
+ foreach (ModFolder folder in toolkit.GetModFolders(rootPath))
{
- // read file
- Manifest manifest = null;
- string path = Path.Combine(modDir.FullName, "manifest.json");
- string error = null;
- try
- {
- manifest = jsonHelper.ReadJsonFile<Manifest>(path);
- if (manifest == null)
- {
- error = File.Exists(path)
- ? "its manifest is invalid."
- : "it doesn't have a manifest.";
- }
- }
- catch (SParseException ex)
- {
- error = $"parsing its manifest failed: {ex.Message}";
- }
- catch (Exception ex)
- {
- error = $"parsing its manifest failed:\n{ex.GetLogSummary()}";
- }
+ Manifest manifest = folder.Manifest;
// parse internal data record (if any)
- ParsedModDataRecord dataRecord = modDatabase.GetParsed(manifest);
+ ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest);
// get display name
string displayName = manifest?.Name;
if (string.IsNullOrWhiteSpace(displayName))
displayName = dataRecord?.DisplayName;
if (string.IsNullOrWhiteSpace(displayName))
- displayName = PathUtilities.GetRelativePath(rootPath, modDir.FullName);
+ displayName = PathUtilities.GetRelativePath(rootPath, folder.ActualDirectory?.FullName ?? folder.SearchDirectory.FullName);
// apply defaults
if (manifest != null && dataRecord != null)
@@ -66,10 +46,10 @@ namespace StardewModdingAPI.Framework.ModLoading
}
// build metadata
- ModMetadataStatus status = error == null
+ ModMetadataStatus status = folder.ManifestParseError == null
? ModMetadataStatus.Found
: ModMetadataStatus.Failed;
- yield return new ModMetadata(displayName, modDir.FullName, manifest, dataRecord).SetStatus(status, error);
+ yield return new ModMetadata(displayName, folder.ActualDirectory?.FullName, manifest, dataRecord).SetStatus(status, folder.ManifestParseError);
}
}
@@ -98,7 +78,7 @@ namespace StardewModdingAPI.Framework.ModLoading
case ModStatus.AssumeBroken:
{
// get reason
- string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's outdated";
+ string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's no longer compatible";
// get update URLs
List<string> updateUrls = new List<string>();
@@ -194,8 +174,15 @@ namespace StardewModdingAPI.Framework.ModLoading
missingFields.Add(nameof(IManifest.UniqueID));
if (missingFields.Any())
+ {
mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)}).");
+ continue;
+ }
}
+
+ // validate ID format
+ if (Regex.IsMatch(mod.Manifest.UniqueID, "[^a-z0-9_.-]", RegexOptions.IgnoreCase))
+ mod.SetStatus(ModMetadataStatus.Failed, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
}
// validate IDs are unique
@@ -292,7 +279,7 @@ namespace StardewModdingAPI.Framework.ModLoading
string[] failedModNames = (
from entry in dependencies
where entry.IsRequired && entry.Mod == null
- let displayName = modDatabase.GetDisplayNameFor(entry.ID) ?? entry.ID
+ let displayName = modDatabase.Get(entry.ID)?.DisplayName ?? entry.ID
let modUrl = modDatabase.GetModPageUrlFor(entry.ID)
orderby displayName
select modUrl != null
diff --git a/src/SMAPI/Framework/ModLoading/ModWarning.cs b/src/SMAPI/Framework/ModLoading/ModWarning.cs
new file mode 100644
index 00000000..0e4b2570
--- /dev/null
+++ b/src/SMAPI/Framework/ModLoading/ModWarning.cs
@@ -0,0 +1,31 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Indicates a detected non-error mod issue.</summary>
+ [Flags]
+ internal enum ModWarning
+ {
+ /// <summary>No issues detected.</summary>
+ None = 0,
+
+ /// <summary>SMAPI detected incompatible code in the mod, but was configured to load it anyway.</summary>
+ BrokenCodeLoaded = 1,
+
+ /// <summary>The mod affects the save serializer in a way that may make saves unloadable without the mod.</summary>
+ ChangesSaveSerialiser = 2,
+
+ /// <summary>The mod patches the game in a way that may impact stability.</summary>
+ PatchesGame = 4,
+
+ /// <summary>The mod uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary>
+ UsesDynamic = 8,
+
+ /// <summary>The mod references <see cref="SpecialisedEvents.UnvalidatedUpdateTick"/> which may impact stability.</summary>
+ UsesUnvalidatedUpdateTick = 16,
+
+ /// <summary>The mod has no update keys set.</summary>
+ NoUpdateKeys = 32
+ }
+}
diff --git a/src/SMAPI/Framework/ModLoading/Platform.cs b/src/SMAPI/Framework/ModLoading/Platform.cs
deleted file mode 100644
index 45e881c4..00000000
--- a/src/SMAPI/Framework/ModLoading/Platform.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace StardewModdingAPI.Framework.ModLoading
-{
- /// <summary>The game's platform version.</summary>
- internal enum Platform
- {
- /// <summary>The Linux/Mac version of the game.</summary>
- Mono,
-
- /// <summary>The Windows version of the game.</summary>
- Windows
- }
-}
diff --git a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs
index 463f45e8..01460dce 100644
--- a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs
+++ b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs
@@ -1,12 +1,14 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Mono.Cecil;
+using StardewModdingAPI.Internal;
namespace StardewModdingAPI.Framework.ModLoading
{
/// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary>
- internal class PlatformAssemblyMap
+ internal class PlatformAssemblyMap : IDisposable
{
/*********
** Accessors
@@ -49,7 +51,14 @@ namespace StardewModdingAPI.Framework.ModLoading
// cache assembly metadata
this.Targets = targetAssemblies;
this.TargetReferences = this.Targets.ToDictionary(assembly => assembly, assembly => AssemblyNameReference.Parse(assembly.FullName));
- this.TargetModules = this.Targets.ToDictionary(assembly => assembly, assembly => ModuleDefinition.ReadModule(assembly.Modules.Single().FullyQualifiedName));
+ this.TargetModules = this.Targets.ToDictionary(assembly => assembly, assembly => ModuleDefinition.ReadModule(assembly.Modules.Single().FullyQualifiedName, new ReaderParameters { InMemory = true }));
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ foreach (ModuleDefinition module in this.TargetModules.Values)
+ module.Dispose();
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/RewriteHelper.cs
index 56a60a72..9ff43d45 100644
--- a/src/SMAPI/Framework/ModLoading/RewriteHelper.cs
+++ b/src/SMAPI/Framework/ModLoading/RewriteHelper.cs
@@ -10,6 +10,13 @@ namespace StardewModdingAPI.Framework.ModLoading
internal static class RewriteHelper
{
/*********
+ ** Properties
+ *********/
+ /// <summary>The comparer which heuristically compares type definitions.</summary>
+ private static readonly TypeReferenceComparer TypeDefinitionComparer = new TypeReferenceComparer();
+
+
+ /*********
** Public methods
*********/
/// <summary>Get the field reference from an instruction if it matches.</summary>
@@ -25,7 +32,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="instruction">The IL instruction.</param>
public static MethodReference AsMethodReference(Instruction instruction)
{
- return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt
+ return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt || instruction.OpCode == OpCodes.Newobj
? (MethodReference)instruction.Operand
: null;
}
@@ -59,6 +66,15 @@ namespace StardewModdingAPI.Framework.ModLoading
return true;
}
+ /// <summary>Determine whether two type IDs look like the same type, accounting for placeholder values such as !0.</summary>
+ /// <param name="typeA">The type ID to compare.</param>
+ /// <param name="typeB">The other type ID to compare.</param>
+ /// <returns>true if the type IDs look like the same type, false if not.</returns>
+ public static bool LooksLikeSameType(TypeReference typeA, TypeReference typeB)
+ {
+ return RewriteHelper.TypeDefinitionComparer.Equals(typeA, typeB);
+ }
+
/// <summary>Get whether a method definition matches the signature expected by a method reference.</summary>
/// <param name="definition">The method definition.</param>
/// <param name="reference">The method reference.</param>
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
index 63358b39..806a074f 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
@@ -42,7 +42,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
if (!this.IsMatch(instruction))
return InstructionHandleResult.None;
- FieldReference newRef = module.Import(this.ToField);
+ FieldReference newRef = module.ImportReference(this.ToField);
cil.Replace(instruction, cil.Create(instruction.OpCode, newRef));
return InstructionHandleResult.Rewritten;
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs
index b1fa377a..e6ede9e3 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs
@@ -50,7 +50,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return InstructionHandleResult.None;
string methodPrefix = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld ? "get" : "set";
- MethodReference propertyRef = module.Import(this.Type.GetMethod($"{methodPrefix}_{this.PropertyName}"));
+ MethodReference propertyRef = module.ImportReference(this.Type.GetMethod($"{methodPrefix}_{this.PropertyName}"));
cil.Replace(instruction, cil.Create(OpCodes.Call, propertyRef));
return InstructionHandleResult.Rewritten;
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
index 974fcf4c..99bd9125 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
@@ -64,7 +64,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return InstructionHandleResult.None;
MethodReference methodRef = (MethodReference)instruction.Operand;
- methodRef.DeclaringType = module.Import(this.ToType);
+ methodRef.DeclaringType = module.ImportReference(this.ToType);
return InstructionHandleResult.Rewritten;
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
index 74f2fcdd..62e15731 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
@@ -24,8 +24,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <summary>Construct an instance.</summary>
/// <param name="fromTypeFullName">The full type name to which to find references.</param>
/// <param name="toType">The new type to reference.</param>
- public TypeReferenceRewriter(string fromTypeFullName, Type toType)
- : base(fromTypeFullName, InstructionHandleResult.None)
+ /// <param name="shouldIgnore">A lambda which overrides a matched type.</param>
+ public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool> shouldIgnore = null)
+ : base(fromTypeFullName, InstructionHandleResult.None, shouldIgnore)
{
this.FromTypeName = fromTypeFullName;
this.ToType = toType;
@@ -43,7 +44,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
// return type
if (this.IsMatch(method.ReturnType))
{
- method.ReturnType = this.RewriteIfNeeded(module, method.ReturnType);
+ this.RewriteIfNeeded(module, method.ReturnType, newType => method.ReturnType = newType);
rewritten = true;
}
@@ -52,7 +53,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
{
if (this.IsMatch(parameter.ParameterType))
{
- parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType);
+ this.RewriteIfNeeded(module, parameter.ParameterType, newType => parameter.ParameterType = newType);
rewritten = true;
}
}
@@ -63,9 +64,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
var parameter = method.GenericParameters[i];
if (this.IsMatch(parameter))
{
- TypeReference newType = this.RewriteIfNeeded(module, parameter);
- if (newType != parameter)
- method.GenericParameters[i] = new GenericParameter(parameter.Name, newType);
+ this.RewriteIfNeeded(module, parameter, newType => method.GenericParameters[i] = new GenericParameter(parameter.Name, newType));
rewritten = true;
}
}
@@ -75,7 +74,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
{
if (this.IsMatch(variable.VariableType))
{
- variable.VariableType = this.RewriteIfNeeded(module, variable.VariableType);
+ this.RewriteIfNeeded(module, variable.VariableType, newType => variable.VariableType = newType);
rewritten = true;
}
}
@@ -93,34 +92,30 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <param name="platformChanged">Whether the mod was compiled on a different platform.</param>
public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged)
{
- if (!this.IsMatch(instruction) && !instruction.ToString().Contains(this.FromTypeName))
+ if (!this.IsMatch(instruction))
return InstructionHandleResult.None;
// field reference
FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null)
{
- fieldRef.DeclaringType = this.RewriteIfNeeded(module, fieldRef.DeclaringType);
- fieldRef.FieldType = this.RewriteIfNeeded(module, fieldRef.FieldType);
+ this.RewriteIfNeeded(module, fieldRef.DeclaringType, newType => fieldRef.DeclaringType = newType);
+ this.RewriteIfNeeded(module, fieldRef.FieldType, newType => fieldRef.FieldType = newType);
}
// method reference
MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null)
{
- methodRef.DeclaringType = this.RewriteIfNeeded(module, methodRef.DeclaringType);
- methodRef.ReturnType = this.RewriteIfNeeded(module, methodRef.ReturnType);
+ this.RewriteIfNeeded(module, methodRef.DeclaringType, newType => methodRef.DeclaringType = newType);
+ this.RewriteIfNeeded(module, methodRef.ReturnType, newType => methodRef.ReturnType = newType);
foreach (var parameter in methodRef.Parameters)
- parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType);
+ this.RewriteIfNeeded(module, parameter.ParameterType, newType => parameter.ParameterType = newType);
}
// type reference
if (instruction.Operand is TypeReference typeRef)
- {
- TypeReference newRef = this.RewriteIfNeeded(module, typeRef);
- if (typeRef != newRef)
- cil.Replace(instruction, cil.Create(instruction.OpCode, newRef));
- }
+ this.RewriteIfNeeded(module, typeRef, newType => cil.Replace(instruction, cil.Create(instruction.OpCode, newType)));
return InstructionHandleResult.Rewritten;
}
@@ -128,27 +123,30 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/*********
** Private methods
*********/
- /// <summary>Get the adjusted type reference if it matches, else the same value.</summary>
+ /// <summary>Change a type reference if needed.</summary>
/// <param name="module">The assembly module containing the instruction.</param>
/// <param name="type">The type to replace if it matches.</param>
- private TypeReference RewriteIfNeeded(ModuleDefinition module, TypeReference type)
+ /// <param name="set">Assign the new type reference.</param>
+ private void RewriteIfNeeded(ModuleDefinition module, TypeReference type, Action<TypeReference> set)
{
- // root type
+ // current type
if (type.FullName == this.FromTypeName)
- return module.Import(this.ToType);
+ {
+ if (!this.ShouldIgnore(type))
+ set(module.ImportReference(this.ToType));
+ return;
+ }
- // generic arguments
+ // recurse into generic arguments
if (type is GenericInstanceType genericType)
{
for (int i = 0; i < genericType.GenericArguments.Count; i++)
- genericType.GenericArguments[i] = this.RewriteIfNeeded(module, genericType.GenericArguments[i]);
+ this.RewriteIfNeeded(module, genericType.GenericArguments[i], typeRef => genericType.GenericArguments[i] = typeRef);
}
- // generic parameters (e.g. constraints)
+ // recurse into generic parameters (e.g. constraints)
for (int i = 0; i < type.GenericParameters.Count; i++)
- type.GenericParameters[i] = new GenericParameter(this.RewriteIfNeeded(module, type.GenericParameters[i]));
-
- return type;
+ this.RewriteIfNeeded(module, type.GenericParameters[i], typeRef => type.GenericParameters[i] = new GenericParameter(typeRef));
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
new file mode 100644
index 00000000..f7497789
--- /dev/null
+++ b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Mono.Cecil;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Performs heuristic equality checks for <see cref="TypeReference"/> instances.</summary>
+ /// <remarks>
+ /// This implementation compares <see cref="TypeReference"/> instances to see if they likely
+ /// refer to the same type. While the implementation is obvious for types like <c>System.Bool</c>,
+ /// this class mainly exists to handle cases like <c>System.Collections.Generic.Dictionary`2&lt;!0,Netcode.NetRoot`1&lt;!1&gt;&gt;</c>
+ /// and <c>System.Collections.Generic.Dictionary`2&lt;TKey,Netcode.NetRoot`1&lt;TValue&gt;&gt;</c>
+ /// which are compatible, but not directly comparable. It does this by splitting each type name
+ /// into its component token types, and performing placeholder substitution (e.g. <c>!0</c> to
+ /// <c>TKey</c> in the above example). If all components are equal after substitution, and the
+ /// tokens can all be mapped to the same generic type, the types are considered equal.
+ /// </remarks>
+ internal class TypeReferenceComparer : IEqualityComparer<TypeReference>
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether the specified objects are equal.</summary>
+ /// <param name="a">The first object to compare.</param>
+ /// <param name="b">The second object to compare.</param>
+ public bool Equals(TypeReference a, TypeReference b)
+ {
+ if (a == null || b == null)
+ return a == b;
+
+ return
+ a == b
+ || a.FullName == b.FullName
+ || this.HeuristicallyEquals(a, b);
+ }
+
+ /// <summary>Get a hash code for the specified object.</summary>
+ /// <param name="obj">The object for which a hash code is to be returned.</param>
+ /// <exception cref="T:System.ArgumentNullException">The object type is a reference type and <paramref name="obj" /> is null.</exception>
+ public int GetHashCode(TypeReference obj)
+ {
+ return obj.GetHashCode();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether two types are heuristically equal based on generic type token substitution.</summary>
+ /// <param name="typeA">The first type to compare.</param>
+ /// <param name="typeB">The second type to compare.</param>
+ private bool HeuristicallyEquals(TypeReference typeA, TypeReference typeB)
+ {
+ bool HeuristicallyEquals(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap)
+ {
+ // analyse type names
+ bool hasTokensA = typeNameA.Contains("!");
+ bool hasTokensB = typeNameB.Contains("!");
+ bool isTokenA = hasTokensA && typeNameA[0] == '!';
+ bool isTokenB = hasTokensB && typeNameB[0] == '!';
+
+ // validate
+ if (!hasTokensA && !hasTokensB)
+ return typeNameA == typeNameB; // no substitution needed
+ if (hasTokensA && hasTokensB)
+ throw new InvalidOperationException("Can't compare two type names when both contain generic type tokens.");
+
+ // perform substitution if applicable
+ if (isTokenA)
+ typeNameA = this.MapPlaceholder(placeholder: typeNameA, type: typeNameB, map: tokenMap);
+ if (isTokenB)
+ typeNameB = this.MapPlaceholder(placeholder: typeNameB, type: typeNameA, map: tokenMap);
+
+ // compare inner tokens
+ string[] symbolsA = this.GetTypeSymbols(typeNameA).ToArray();
+ string[] symbolsB = this.GetTypeSymbols(typeNameB).ToArray();
+ if (symbolsA.Length != symbolsB.Length)
+ return false;
+
+ for (int i = 0; i < symbolsA.Length; i++)
+ {
+ if (!HeuristicallyEquals(symbolsA[i], symbolsB[i], tokenMap))
+ return false;
+ }
+
+ return true;
+ }
+
+ return HeuristicallyEquals(typeA.FullName, typeB.FullName, new Dictionary<string, string>());
+ }
+
+ /// <summary>Map a generic type placeholder (like <c>!0</c>) to its actual type.</summary>
+ /// <param name="placeholder">The token placeholder.</param>
+ /// <param name="type">The actual type.</param>
+ /// <param name="map">The map of token to map substitutions.</param>
+ /// <returns>Returns the previously-mapped type if applicable, else the <paramref name="type"/>.</returns>
+ private string MapPlaceholder(string placeholder, string type, IDictionary<string, string> map)
+ {
+ if (map.TryGetValue(placeholder, out string result))
+ return result;
+
+ map[placeholder] = type;
+ return type;
+ }
+
+ /// <summary>Get the top-level type symbols in a type name (e.g. <code>List</code> and <code>NetRef&lt;T&gt;</code> in <code>List&lt;NetRef&lt;T&gt;&gt;</code>)</summary>
+ /// <param name="typeName">The full type name.</param>
+ private IEnumerable<string> GetTypeSymbols(string typeName)
+ {
+ int openGenerics = 0;
+
+ Queue<char> queue = new Queue<char>(typeName);
+ string symbol = "";
+ while (queue.Any())
+ {
+ char ch = queue.Dequeue();
+ switch (ch)
+ {
+ // skip `1 generic type identifiers
+ case '`':
+ while (int.TryParse(queue.Peek().ToString(), out int _))
+ queue.Dequeue();
+ break;
+
+ // start generic args
+ case '<':
+ switch (openGenerics)
+ {
+ // start new generic symbol
+ case 0:
+ yield return symbol;
+ symbol = "";
+ openGenerics++;
+ break;
+
+ // continue accumulating nested type symbol
+ default:
+ symbol += ch;
+ openGenerics++;
+ break;
+ }
+ break;
+
+ // generic args delimiter
+ case ',':
+ switch (openGenerics)
+ {
+ // invalid
+ case 0:
+ throw new InvalidOperationException($"Encountered unexpected comma in type name: {typeName}.");
+
+ // start next generic symbol
+ case 1:
+ yield return symbol;
+ symbol = "";
+ break;
+
+ // continue accumulating nested type symbol
+ default:
+ symbol += ch;
+ break;
+ }
+ break;
+
+
+ // end generic args
+ case '>':
+ switch (openGenerics)
+ {
+ // invalid
+ case 0:
+ throw new InvalidOperationException($"Encountered unexpected closing generic in type name: {typeName}.");
+
+ // end generic symbol
+ case 1:
+ yield return symbol;
+ symbol = "";
+ openGenerics--;
+ break;
+
+ // continue accumulating nested type symbol
+ default:
+ symbol += ch;
+ openGenerics--;
+ break;
+ }
+ break;
+
+ // continue symbol
+ default:
+ symbol += ch;
+ break;
+ }
+ }
+
+ if (symbol != "")
+ yield return symbol;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Models/ModFolderExport.cs b/src/SMAPI/Framework/Models/ModFolderExport.cs
new file mode 100644
index 00000000..3b8d451a
--- /dev/null
+++ b/src/SMAPI/Framework/Models/ModFolderExport.cs
@@ -0,0 +1,21 @@
+namespace StardewModdingAPI.Framework.Models
+{
+ /// <summary>Metadata exported to the mod folder.</summary>
+ internal class ModFolderExport
+ {
+ /// <summary>When the export was generated.</summary>
+ public string Exported { get; set; }
+
+ /// <summary>The absolute path of the mod folder.</summary>
+ public string ModFolderPath { get; set; }
+
+ /// <summary>The game version which last loaded the mods.</summary>
+ public string GameVersion { get; set; }
+
+ /// <summary>The SMAPI version which last loaded the mods.</summary>
+ public string ApiVersion { get; set; }
+
+ /// <summary>The detected mods.</summary>
+ public IModMetadata[] Mods { get; set; }
+ }
+}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 17169714..15671af4 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -1,5 +1,4 @@
-using System.Collections.Generic;
-using StardewModdingAPI.Framework.ModData;
+using StardewModdingAPI.Internal.ConsoleWriting;
namespace StardewModdingAPI.Framework.Models
{
@@ -15,6 +14,9 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary>
public bool CheckForUpdates { get; set; }
+ /// <summary>Whether to show beta versions as valid updates.</summary>
+ public bool UseBetaChannel { get; set; } = Constants.ApiVersion.IsPrerelease();
+
/// <summary>SMAPI's GitHub project name, used to perform update checks.</summary>
public string GitHubProjectName { get; set; }
@@ -24,7 +26,13 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should log more information about the game context.</summary>
public bool VerboseLogging { get; set; }
- /// <summary>Extra metadata about mods.</summary>
- public IDictionary<string, ModDataRecord> ModData { get; set; }
+ /// <summary>Whether to generate a file in the mods folder with detailed metadata about the detected mods.</summary>
+ public bool DumpMetadata { get; set; }
+
+ /// <summary>The console color scheme to use.</summary>
+ public MonitorColorScheme ColorScheme { get; set; }
+
+ /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary>
+ public string[] SuppressUpdateChecks { get; set; }
}
}
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index bf338386..2812a9cc 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -1,8 +1,8 @@
using System;
-using System.Collections.Generic;
using System.Linq;
using System.Threading;
using StardewModdingAPI.Framework.Logging;
+using StardewModdingAPI.Internal.ConsoleWriting;
namespace StardewModdingAPI.Framework
{
@@ -15,8 +15,11 @@ namespace StardewModdingAPI.Framework
/// <summary>The name of the module which logs messages using this instance.</summary>
private readonly string Source;
+ /// <summary>Handles writing color-coded text to the console.</summary>
+ private readonly ColorfulConsoleWriter ConsoleWriter;
+
/// <summary>Manages access to the console output.</summary>
- private readonly ConsoleInterceptionManager ConsoleManager;
+ private readonly ConsoleInterceptionManager ConsoleInterceptor;
/// <summary>The log file to which to write messages.</summary>
private readonly LogFileManager LogFile;
@@ -24,9 +27,6 @@ namespace StardewModdingAPI.Framework
/// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary>
private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max();
- /// <summary>The console text color for each log level.</summary>
- private static readonly IDictionary<LogLevel, ConsoleColor> Colors = Monitor.GetConsoleColorScheme();
-
/// <summary>Propagates notification that SMAPI should exit.</summary>
private readonly CancellationTokenSource ExitTokenSource;
@@ -46,19 +46,17 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether to write anything to the console. This should be disabled if no console is available.</summary>
internal bool WriteToConsole { get; set; } = true;
- /// <summary>Whether to write anything to the log file. This should almost always be enabled.</summary>
- internal bool WriteToFile { get; set; } = true;
-
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="source">The name of the module which logs messages using this instance.</param>
- /// <param name="consoleManager">Manages access to the console output.</param>
+ /// <param name="consoleInterceptor">Intercepts access to the console output.</param>
/// <param name="logFile">The log file to which to write messages.</param>
/// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param>
- public Monitor(string source, ConsoleInterceptionManager consoleManager, LogFileManager logFile, CancellationTokenSource exitTokenSource)
+ /// <param name="colorScheme">The console color scheme to use.</param>
+ public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme)
{
// validate
if (string.IsNullOrWhiteSpace(source))
@@ -67,7 +65,8 @@ namespace StardewModdingAPI.Framework
// initialise
this.Source = source;
this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null.");
- this.ConsoleManager = consoleManager;
+ this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorScheme);
+ this.ConsoleInterceptor = consoleInterceptor;
this.ExitTokenSource = exitTokenSource;
}
@@ -76,7 +75,7 @@ namespace StardewModdingAPI.Framework
/// <param name="level">The log severity level.</param>
public void Log(string message, LogLevel level = LogLevel.Debug)
{
- this.LogImpl(this.Source, message, level, Monitor.Colors[level]);
+ this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
}
/// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary>
@@ -91,9 +90,17 @@ namespace StardewModdingAPI.Framework
internal void Newline()
{
if (this.WriteToConsole)
- this.ConsoleManager.ExclusiveWriteWithoutInterception(Console.WriteLine);
- if (this.WriteToFile)
- this.LogFile.WriteLine("");
+ this.ConsoleInterceptor.ExclusiveWriteWithoutInterception(Console.WriteLine);
+ this.LogFile.WriteLine("");
+ }
+
+ /// <summary>Log console input from the user.</summary>
+ /// <param name="input">The user input to log.</param>
+ internal void LogUserInput(string input)
+ {
+ // user input already appears in the console, so just need to write to file
+ string prefix = this.GenerateMessagePrefix(this.Source, (ConsoleLogLevel)LogLevel.Info);
+ this.LogFile.WriteLine($"{prefix} $>{input}");
}
@@ -104,91 +111,40 @@ namespace StardewModdingAPI.Framework
/// <param name="message">The message to log.</param>
private void LogFatal(string message)
{
- this.LogImpl(this.Source, message, LogLevel.Error, ConsoleColor.White, background: ConsoleColor.Red);
+ this.LogImpl(this.Source, message, ConsoleLogLevel.Critical);
}
/// <summary>Write a message line to the log.</summary>
/// <param name="source">The name of the mod logging the message.</param>
/// <param name="message">The message to log.</param>
/// <param name="level">The log level.</param>
- /// <param name="color">The console foreground color.</param>
- /// <param name="background">The console background color (or <c>null</c> to leave it as-is).</param>
- private void LogImpl(string source, string message, LogLevel level, ConsoleColor color, ConsoleColor? background = null)
+ private void LogImpl(string source, string message, ConsoleLogLevel level)
{
// generate message
- string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength);
-
- string fullMessage = $"[{DateTime.Now:HH:mm:ss} {levelStr} {source}] {message}";
+ string prefix = this.GenerateMessagePrefix(source, level);
+ string fullMessage = $"{prefix} {message}";
string consoleMessage = this.ShowFullStampInConsole ? fullMessage : $"[{source}] {message}";
// write to console
- if (this.WriteToConsole && (this.ShowTraceInConsole || level != LogLevel.Trace))
+ if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace))
{
- this.ConsoleManager.ExclusiveWriteWithoutInterception(() =>
+ this.ConsoleInterceptor.ExclusiveWriteWithoutInterception(() =>
{
- if (this.ConsoleManager.SupportsColor)
- {
- if (background.HasValue)
- Console.BackgroundColor = background.Value;
- Console.ForegroundColor = color;
- Console.WriteLine(consoleMessage);
- Console.ResetColor();
- }
- else
- Console.WriteLine(consoleMessage);
+ this.ConsoleWriter.WriteLine(consoleMessage, level);
});
}
// write to log file
- if (this.WriteToFile)
- this.LogFile.WriteLine(fullMessage);
+ this.LogFile.WriteLine(fullMessage);
}
- /// <summary>Get the color scheme to use for the current console.</summary>
- private static IDictionary<LogLevel, ConsoleColor> GetConsoleColorScheme()
- {
- // scheme for dark console background
- if (Monitor.IsDark(Console.BackgroundColor))
- {
- return new Dictionary<LogLevel, ConsoleColor>
- {
- [LogLevel.Trace] = ConsoleColor.DarkGray,
- [LogLevel.Debug] = ConsoleColor.DarkGray,
- [LogLevel.Info] = ConsoleColor.White,
- [LogLevel.Warn] = ConsoleColor.Yellow,
- [LogLevel.Error] = ConsoleColor.Red,
- [LogLevel.Alert] = ConsoleColor.Magenta
- };
- }
-
- // scheme for light console background
- return new Dictionary<LogLevel, ConsoleColor>
- {
- [LogLevel.Trace] = ConsoleColor.DarkGray,
- [LogLevel.Debug] = ConsoleColor.DarkGray,
- [LogLevel.Info] = ConsoleColor.Black,
- [LogLevel.Warn] = ConsoleColor.DarkYellow,
- [LogLevel.Error] = ConsoleColor.Red,
- [LogLevel.Alert] = ConsoleColor.DarkMagenta
- };
- }
-
- /// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary>
- /// <param name="color">The color to check.</param>
- private static bool IsDark(ConsoleColor color)
+ /// <summary>Generate a message prefix for the current time.</summary>
+ /// <param name="source">The name of the mod logging the message.</param>
+ /// <param name="level">The log level.</param>
+ private string GenerateMessagePrefix(string source, ConsoleLogLevel level)
{
- switch (color)
- {
- case ConsoleColor.Black:
- case ConsoleColor.Blue:
- case ConsoleColor.DarkBlue:
- case ConsoleColor.DarkRed:
- case ConsoleColor.Red:
- return true;
-
- default:
- return false;
- }
+ string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength);
+ return $"[{DateTime.Now:HH:mm:ss} {levelStr} {source}]";
}
}
}
diff --git a/src/SMAPI/Framework/Patching/GamePatcher.cs b/src/SMAPI/Framework/Patching/GamePatcher.cs
new file mode 100644
index 00000000..71ca8e55
--- /dev/null
+++ b/src/SMAPI/Framework/Patching/GamePatcher.cs
@@ -0,0 +1,45 @@
+using System;
+using Harmony;
+
+namespace StardewModdingAPI.Framework.Patching
+{
+ /// <summary>Encapsulates applying Harmony patches to the game.</summary>
+ internal class GamePatcher
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ private readonly IMonitor Monitor;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ public GamePatcher(IMonitor monitor)
+ {
+ this.Monitor = monitor;
+ }
+
+ /// <summary>Apply all loaded patches to the game.</summary>
+ /// <param name="patches">The patches to apply.</param>
+ public void Apply(params IHarmonyPatch[] patches)
+ {
+ HarmonyInstance harmony = HarmonyInstance.Create("io.smapi");
+ foreach (IHarmonyPatch patch in patches)
+ {
+ try
+ {
+ patch.Apply(harmony);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Couldn't apply runtime patch '{patch.Name}' to the game. Some SMAPI features may not work correctly. See log file for details.", LogLevel.Error);
+ this.Monitor.Log(ex.GetLogSummary(), LogLevel.Trace);
+ }
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Patching/IHarmonyPatch.cs b/src/SMAPI/Framework/Patching/IHarmonyPatch.cs
new file mode 100644
index 00000000..cb42f40e
--- /dev/null
+++ b/src/SMAPI/Framework/Patching/IHarmonyPatch.cs
@@ -0,0 +1,15 @@
+using Harmony;
+
+namespace StardewModdingAPI.Framework.Patching
+{
+ /// <summary>A Harmony patch to apply.</summary>
+ internal interface IHarmonyPatch
+ {
+ /// <summary>A unique name for this patch.</summary>
+ string Name { get; }
+
+ /// <summary>Apply the Harmony patch.</summary>
+ /// <param name="harmony">The Harmony instance.</param>
+ void Apply(HarmonyInstance harmony);
+ }
+}
diff --git a/src/SMAPI/Framework/Reflection/ReflectedField.cs b/src/SMAPI/Framework/Reflection/ReflectedField.cs
index fb420dc5..09638b1d 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedField.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedField.cs
@@ -6,9 +6,6 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>A field obtained through reflection.</summary>
/// <typeparam name="TValue">The field value type.</typeparam>
internal class ReflectedField<TValue> : IReflectedField<TValue>
-#if !STARDEW_VALLEY_1_3
- , IPrivateField<TValue>
-#endif
{
/*********
** Properties
diff --git a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
index 803bc316..7d9072a0 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
@@ -5,9 +5,6 @@ namespace StardewModdingAPI.Framework.Reflection
{
/// <summary>A method obtained through reflection.</summary>
internal class ReflectedMethod : IReflectedMethod
-#if !STARDEW_VALLEY_1_3
- , IPrivateMethod
-#endif
{
/*********
** Properties
diff --git a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs
index 4f9d4e19..d59b71ac 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs
@@ -6,9 +6,6 @@ namespace StardewModdingAPI.Framework.Reflection
/// <summary>A property obtained through reflection.</summary>
/// <typeparam name="TValue">The property value type.</typeparam>
internal class ReflectedProperty<TValue> : IReflectedProperty<TValue>
-#if !STARDEW_VALLEY_1_3
- , IPrivateProperty<TValue>
-#endif
{
/*********
** Properties
diff --git a/src/SMAPI.AssemblyRewriters/SpriteBatchMethods.cs b/src/SMAPI/Framework/RewriteFacades/SpriteBatchMethods.cs
index a7f100f2..26b22315 100644
--- a/src/SMAPI.AssemblyRewriters/SpriteBatchMethods.cs
+++ b/src/SMAPI/Framework/RewriteFacades/SpriteBatchMethods.cs
@@ -2,9 +2,11 @@ using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
-namespace StardewModdingAPI.AssemblyRewriters
+#pragma warning disable 1591 // missing documentation
+namespace StardewModdingAPI.Framework.RewriteFacades
{
/// <summary>Provides <see cref="SpriteBatch"/> method signatures that can be injected into mod code for compatibility between Linux/Mac or Windows.</summary>
+ /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks>
public class SpriteBatchMethods : SpriteBatch
{
/*********
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index c6e9aa92..05fedc3d 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -1,7 +1,7 @@
using System;
-using System.Collections;
+using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Diagnostics;
+using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
@@ -10,25 +10,23 @@ using System.Threading.Tasks;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
-#if STARDEW_VALLEY_1_3
using Netcode;
-#endif
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.StateTracking;
using StardewModdingAPI.Framework.Utilities;
using StardewValley;
using StardewValley.BellsAndWhistles;
+using StardewValley.Buildings;
using StardewValley.Locations;
using StardewValley.Menus;
+using StardewValley.TerrainFeatures;
using StardewValley.Tools;
using xTile.Dimensions;
-#if !STARDEW_VALLEY_1_3
using xTile.Layers;
-#else
-using SFarmer = StardewValley.Farmer;
-#endif
+using Object = StardewValley.Object;
namespace StardewModdingAPI.Framework
{
@@ -65,10 +63,10 @@ namespace StardewModdingAPI.Framework
/// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary>
/// <remarks>Skipping a few frames ensures the game finishes initialising the world before mods try to change it.</remarks>
- private int AfterLoadTimer = 5;
+ private readonly Countdown AfterLoadTimer = new Countdown(5);
- /// <summary>Whether the game is returning to the menu.</summary>
- private bool IsExitingToTitle;
+ /// <summary>Whether the after-load events were raised for this session.</summary>
+ private bool RaisedAfterLoadEvent;
/// <summary>Whether the game is saving and SMAPI has already raised <see cref="SaveEvents.BeforeSave"/>.</summary>
private bool IsBetweenSaveEvents;
@@ -76,114 +74,56 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="SaveEvents.BeforeCreate"/>.</summary>
private bool IsBetweenCreateEvents;
- /****
- ** Game state
- ****/
- /// <summary>The player input as of the previous tick.</summary>
- private InputState PreviousInput = new InputState();
-
- /// <summary>The window size value at last check.</summary>
- private Point PreviousWindowSize;
-
- /// <summary>The save ID at last check.</summary>
- private ulong PreviousSaveID;
-
- /// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary>
- private int PreviousGameLocations;
-
- /// <summary>A hash of the current location's <see cref="GameLocation.objects"/> at last check.</summary>
- private int PreviousLocationObjects;
-
- /// <summary>The player's inventory at last check.</summary>
- private IDictionary<Item, int> PreviousItems;
-
- /// <summary>The player's combat skill level at last check.</summary>
- private int PreviousCombatLevel;
-
- /// <summary>The player's farming skill level at last check.</summary>
- private int PreviousFarmingLevel;
-
- /// <summary>The player's fishing skill level at last check.</summary>
- private int PreviousFishingLevel;
-
- /// <summary>The player's foraging skill level at last check.</summary>
- private int PreviousForagingLevel;
-
- /// <summary>The player's mining skill level at last check.</summary>
- private int PreviousMiningLevel;
-
- /// <summary>The player's luck skill level at last check.</summary>
- private int PreviousLuckLevel;
-
- /// <summary>The player's location at last check.</summary>
- private GameLocation PreviousGameLocation;
-
- /// <summary>The active game menu at last check.</summary>
- private IClickableMenu PreviousActiveMenu;
+ /// <summary>A callback to invoke after the game finishes initialising.</summary>
+ private readonly Action OnGameInitialised;
- /// <summary>The mine level at last check.</summary>
- private int PreviousMineLevel;
+ /// <summary>A callback to invoke when the game exits.</summary>
+ private readonly Action OnGameExiting;
- /// <summary>The time of day (in 24-hour military format) at last check.</summary>
- private int PreviousTime;
+ /// <summary>Simplifies access to private game code.</summary>
+ private readonly Reflector Reflection;
- /// <summary>The previous content locale.</summary>
- private LocalizedContentManager.LanguageCode? PreviousLocale;
+ /****
+ ** Game state
+ ****/
+ /// <summary>Monitors the entire game state for changes.</summary>
+ private WatcherCore Watchers;
/// <summary>An index incremented on every tick and reset every 60th tick (0–59).</summary>
private int CurrentUpdateTick;
- /// <summary>Whether this is the very first update tick since the game started.</summary>
- private bool FirstUpdate;
+ /// <summary>Whether post-game-startup initialisation has been performed.</summary>
+ private bool IsInitialised;
-#if !STARDEW_VALLEY_1_3
- /// <summary>The current game instance.</summary>
- private static SGame Instance;
-#endif
+ /// <summary>The number of update ticks which have already executed.</summary>
+ private uint TicksElapsed;
- /// <summary>A callback to invoke after the game finishes initialising.</summary>
- private readonly Action OnGameInitialised;
-
- /****
- ** Private wrappers
- ****/
- /// <summary>Simplifies access to private game code.</summary>
- private static Reflector Reflection;
-
-#if !STARDEW_VALLEY_1_3
- // ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming
- /// <summary>Used to access private fields and methods.</summary>
- private static List<float> _fpsList => SGame.Reflection.GetField<List<float>>(typeof(Game1), nameof(_fpsList)).GetValue();
- private static Stopwatch _fpsStopwatch => SGame.Reflection.GetField<Stopwatch>(typeof(Game1), nameof(SGame._fpsStopwatch)).GetValue();
- private static float _fps
- {
- set => SGame.Reflection.GetField<float>(typeof(Game1), nameof(_fps)).SetValue(value);
- }
- private static Task _newDayTask => SGame.Reflection.GetField<Task>(typeof(Game1), nameof(_newDayTask)).GetValue();
- private Color bgColor => SGame.Reflection.GetField<Color>(this, nameof(this.bgColor)).GetValue();
- public RenderTarget2D screenWrapper => SGame.Reflection.GetProperty<RenderTarget2D>(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop
- public BlendState lightingBlend => SGame.Reflection.GetField<BlendState>(this, nameof(this.lightingBlend)).GetValue();
- private readonly Action drawFarmBuildings = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke();
- private readonly Action drawHUD = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawHUD)).Invoke();
- private readonly Action drawDialogueBox = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke();
- private readonly Action renderScreenBuffer = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke();
- // ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming
-#endif
-
-#if STARDEW_VALLEY_1_3
- private static StringBuilder _debugStringBuilder => SGame.Reflection.GetField<StringBuilder>(typeof(Game1), nameof(_debugStringBuilder)).GetValue();
-#endif
+ /// <summary>Whether the next content manager requested by the game will be for <see cref="Game1.content"/>.</summary>
+ private bool NextContentManagerIsMain;
/*********
** Accessors
*********/
/// <summary>SMAPI's content manager.</summary>
- public ContentCore ContentCore { get; private set; }
+ public ContentCoordinator ContentCore { get; private set; }
+
+ /// <summary>Manages console commands.</summary>
+ public CommandManager CommandManager { get; } = new CommandManager();
+
+ /// <summary>Manages input visible to the game.</summary>
+ public SInputState Input => (SInputState)Game1.input;
+
+ /// <summary>The game's core multiplayer utility.</summary>
+ public SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer;
/// <summary>Whether SMAPI should log more information about the game context.</summary>
public bool VerboseLogging { get; set; }
+ /// <summary>A list of queued commands to execute.</summary>
+ /// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks>
+ public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
+
/*********
** Protected methods
@@ -193,29 +133,50 @@ namespace StardewModdingAPI.Framework
/// <param name="reflection">Simplifies access to private game code.</param>
/// <param name="eventManager">Manages SMAPI events for mods.</param>
/// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param>
- internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, Action onGameInitialised)
+ /// <param name="onGameExiting">A callback to invoke when the game exits.</param>
+ internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, Action onGameInitialised, Action onGameExiting)
{
- // initialise
+ // check expectations
+ if (this.ContentCore == null)
+ throw new InvalidOperationException($"The game didn't initialise its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change.");
+
+ // init XNA
+ Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef;
+
+ // init SMAPI
this.Monitor = monitor;
this.Events = eventManager;
- this.FirstUpdate = true;
-#if !STARDEW_VALLEY_1_3
- SGame.Instance = this;
-#endif
- SGame.Reflection = reflection;
+ this.Reflection = reflection;
this.OnGameInitialised = onGameInitialised;
- if (this.ContentCore == null) // shouldn't happen since CreateContentManager is called first, but let's init here just in case
- this.ContentCore = new ContentCore(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor, reflection);
+ this.OnGameExiting = onGameExiting;
+ Game1.input = new SInputState();
+ Game1.multiplayer = new SMultiplayer(monitor, eventManager);
- // set XNA option required by Stardew Valley
- Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef;
+ // init observables
+ Game1.locations = new ObservableCollection<GameLocation>();
+ }
+
+ /// <summary>Initialise just before the game's first update tick.</summary>
+ private void InitialiseAfterGameStarted()
+ {
+ // set initial state
+ this.Input.TrueUpdate();
+
+ // init watchers
+ this.Watchers = new WatcherCore(this.Input);
+
+ // raise callback
+ this.OnGameInitialised();
+ }
-#if !STARDEW_VALLEY_1_3
- // replace already-created content managers
- this.Monitor?.Log("Overriding content manager...", LogLevel.Trace);
- this.Content = this.ContentCore.CreateContentManager("SGame.Content");
- reflection.GetField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(this.ContentCore.CreateContentManager("Game1._temporaryContent")); // regenerate value with new content manager
-#endif
+ /// <summary>Perform cleanup logic when the game exits.</summary>
+ /// <param name="sender">The event sender.</param>
+ /// <param name="args">The event args.</param>
+ /// <remarks>This overrides the logic in <see cref="Game1.exitEvent"/> to let SMAPI clean up before exit.</remarks>
+ protected override void OnExiting(object sender, EventArgs args)
+ {
+ Game1.multiplayer.Disconnect();
+ this.OnGameExiting?.Invoke();
}
/****
@@ -226,14 +187,25 @@ namespace StardewModdingAPI.Framework
/// <param name="rootDirectory">The root directory to search for content.</param>
protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory)
{
- // NOTE: this method is called from the Game1 constructor, before the SGame constructor runs.
- // Don't depend on anything being initialised at this point.
+ // Game1._temporaryContent initialising from SGame constructor
+ // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialised at this point.
if (this.ContentCore == null)
{
- this.ContentCore = new ContentCore(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, null, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation);
+ this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation);
SGame.MonitorDuringInitialisation = null;
+ this.NextContentManagerIsMain = true;
+ return this.ContentCore.CreateGameContentManager("Game1._temporaryContent");
}
- return this.ContentCore.CreateContentManager("(generated)", rootDirectory);
+
+ // Game1.content initialising from LoadContent
+ if (this.NextContentManagerIsMain)
+ {
+ this.NextContentManagerIsMain = false;
+ return this.ContentCore.MainContentManager;
+ }
+
+ // any other content manager
+ return this.ContentCore.CreateGameContentManager("(generated)");
}
/// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary>
@@ -243,45 +215,83 @@ namespace StardewModdingAPI.Framework
try
{
/*********
- ** Skip conditions
+ ** Special cases
*********/
- // SMAPI exiting, stop processing game updates
+ // Perform first-tick initialisation.
+ if (!this.IsInitialised)
+ {
+ this.IsInitialised = true;
+ this.InitialiseAfterGameStarted();
+ }
+
+ // Abort if SMAPI is exiting.
if (this.Monitor.IsExiting)
{
this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace);
return;
}
- // While a background new-day task is in progress, the game skips its own update logic
- // and defers to the XNA Update method. Running mod code in parallel to the background
- // update is risky, because data changes can conflict (e.g. collection changed during
- // enumeration errors) and data may change unexpectedly from one mod instruction to the
- // next.
+ // Run async tasks synchronously to avoid issues due to mod events triggering
+ // concurrently with game code.
+ if (Game1.currentLoader != null)
+ {
+ this.Monitor.Log("Game loader synchronising...", LogLevel.Trace);
+ while (Game1.currentLoader?.MoveNext() == true)
+ ;
+ Game1.currentLoader = null;
+ this.Monitor.Log("Game loader done.", LogLevel.Trace);
+ }
+ if (Game1._newDayTask?.Status == TaskStatus.Created)
+ {
+ this.Monitor.Log("New day task synchronising...", LogLevel.Trace);
+ Game1._newDayTask.RunSynchronously();
+ this.Monitor.Log("New day task done.", LogLevel.Trace);
+ }
+
+ // While a background task is in progress, the game may make changes to the game
+ // state while mods are running their code. This is risky, because data changes can
+ // conflict (e.g. collection changed during enumeration errors) and data may change
+ // unexpectedly from one mod instruction to the next.
//
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are neglible and not worth the complications of bypassing Game1.Update.
-#if STARDEW_VALLEY_1_3
- if (Game1._newDayTask != null)
-#else
- if (SGame._newDayTask != null)
-#endif
+ if (Game1._newDayTask != null || Game1.gameMode == Game1.loadingMode)
{
base.Update(gameTime);
this.Events.Specialised_UnvalidatedUpdateTick.Raise();
return;
}
- // game is asynchronously loading a save, block mod events to avoid conflicts
- if (Game1.gameMode == Game1.loadingMode)
+ /*********
+ ** Execute commands
+ *********/
+ while (this.CommandQueue.TryDequeue(out string rawInput))
{
- base.Update(gameTime);
- this.Events.Specialised_UnvalidatedUpdateTick.Raise();
- return;
+ try
+ {
+ if (!this.CommandManager.Trigger(rawInput))
+ this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
}
/*********
+ ** Update input
+ *********/
+ // This should *always* run, even when suppressing mod events, since the game uses
+ // this too. For example, doing this after mod event suppression would prevent the
+ // user from doing anything on the overnight shipping screen.
+ SInputState previousInputState = this.Input.Clone();
+ SInputState inputState = this.Input;
+ if (this.IsActive)
+ inputState.TrueUpdate();
+
+ /*********
** Save events + suppress events during save
*********/
// While the game is writing to the save file in the background, mods can unexpectedly
@@ -329,61 +339,63 @@ namespace StardewModdingAPI.Framework
}
/*********
- ** Notify SMAPI that game is initialised
+ ** Update context
*********/
- if (this.FirstUpdate)
- this.OnGameInitialised();
+ bool wasWorldReady = Context.IsWorldReady;
+ if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle)
+ this.MarkWorldNotReady();
+ else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null)
+ {
+ if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet)
+ this.AfterLoadTimer.Decrement();
+ Context.IsWorldReady = this.AfterLoadTimer.Current == 0;
+ }
/*********
- ** Locale changed events
+ ** Update watchers
*********/
- if (this.PreviousLocale != LocalizedContentManager.CurrentLanguageCode)
- {
- var oldValue = this.PreviousLocale;
- var newValue = LocalizedContentManager.CurrentLanguageCode;
-
- this.Monitor.Log($"Context: locale set to {newValue}.", LogLevel.Trace);
-
- if (oldValue != null)
- this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged<string>(oldValue.ToString(), newValue.ToString()));
-
- this.PreviousLocale = newValue;
- }
+ this.Watchers.Update();
/*********
- ** After load events
+ ** Locale changed events
*********/
- if (Context.IsSaveLoaded && !SaveGame.IsProcessing /*still loading save*/ && this.AfterLoadTimer >= 0)
+ if (this.Watchers.LocaleWatcher.IsChanged)
{
- if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet)
- this.AfterLoadTimer--;
+ var was = this.Watchers.LocaleWatcher.PreviousValue;
+ var now = this.Watchers.LocaleWatcher.CurrentValue;
- if (this.AfterLoadTimer == 0)
- {
- this.Monitor.Log($"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace);
- Context.IsWorldReady = true;
+ this.Monitor.Log($"Context: locale set to {now}.", LogLevel.Trace);
+ this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString()));
- this.Events.Save_AfterLoad.Raise();
- this.Events.Time_AfterDayStarted.Raise();
- }
+ this.Watchers.LocaleWatcher.Reset();
}
/*********
- ** Exit to title events
+ ** Load / return-to-title events
*********/
- // before exit to title
- if (Game1.exitToTitle)
- this.IsExitingToTitle = true;
-
- // after exit to title
- if (Context.IsWorldReady && this.IsExitingToTitle && Game1.activeClickableMenu is TitleMenu)
+ if (wasWorldReady && !Context.IsWorldReady)
{
this.Monitor.Log("Context: returned to title", LogLevel.Trace);
-
- this.IsExitingToTitle = false;
- this.CleanupAfterReturnToTitle();
this.Events.Save_AfterReturnToTitle.Raise();
}
+ else if (!this.RaisedAfterLoadEvent && Context.IsWorldReady)
+ {
+ // print context
+ string context = $"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.";
+ if (Context.IsMultiplayer)
+ {
+ int onlineCount = Game1.getOnlineFarmers().Count();
+ context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online.";
+ }
+ else
+ context += " Single-player.";
+ this.Monitor.Log(context, LogLevel.Trace);
+
+ // raise events
+ this.RaisedAfterLoadEvent = true;
+ this.Events.Save_AfterLoad.Raise();
+ this.Events.Time_AfterDayStarted.Raise();
+ }
/*********
** Window events
@@ -392,123 +404,124 @@ namespace StardewModdingAPI.Framework
// event because we need to notify mods after the game handles the resize, so the
// game's metadata (like Game1.viewport) are updated. That's a bit complicated
// since the game adds & removes its own handler on the fly.
- if (Game1.viewport.Width != this.PreviousWindowSize.X || Game1.viewport.Height != this.PreviousWindowSize.Y)
+ if (this.Watchers.WindowSizeWatcher.IsChanged)
{
- Point size = new Point(Game1.viewport.Width, Game1.viewport.Height);
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Events: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace);
this.Events.Graphics_Resize.Raise();
- this.PreviousWindowSize = size;
+ this.Watchers.WindowSizeWatcher.Reset();
}
/*********
** Input events (if window has focus)
*********/
- if (Game1.game1.IsActive)
+ if (this.IsActive)
{
- // get input state
- InputState inputState;
- try
+ // raise events
+ bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton));
+ if (!isChatInput)
{
- inputState = InputState.GetState(this.PreviousInput);
- }
- catch (InvalidOperationException) // GetState() may crash for some players if window doesn't have focus but game1.IsActive == true
- {
- inputState = this.PreviousInput;
- }
+ ICursorPosition cursor = this.Input.CursorPosition;
- // get cursor position
- ICursorPosition cursor;
- {
- // cursor position
- Vector2 screenPixels = new Vector2(Game1.getMouseX(), Game1.getMouseY());
- Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize));
- Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton
- ? tile
- : Game1.player.GetGrabTile();
- cursor = new CursorPosition(screenPixels, tile, grabTile);
- }
+ // raise cursor moved event
+ if (this.Watchers.CursorWatcher.IsChanged)
+ {
+ ICursorPosition was = this.Watchers.CursorWatcher.PreviousValue;
+ ICursorPosition now = this.Watchers.CursorWatcher.CurrentValue;
+ this.Watchers.CursorWatcher.Reset();
- // raise input events
- foreach (var pair in inputState.ActiveButtons)
- {
- SButton button = pair.Key;
- InputStatus status = pair.Value;
+ this.Events.Input_CursorMoved.Raise(new InputCursorMovedEventArgs(was, now));
+ }
- if (status == InputStatus.Pressed)
+ // raise mouse wheel scrolled
+ if (this.Watchers.MouseWheelScrollWatcher.IsChanged)
{
- this.Events.Input_ButtonPressed.Raise(new EventArgsInput(button, cursor, button.IsActionButton(), button.IsUseToolButton()));
+ int was = this.Watchers.MouseWheelScrollWatcher.PreviousValue;
+ int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue;
+ this.Watchers.MouseWheelScrollWatcher.Reset();
- // legacy events
- if (button.TryGetKeyboard(out Keys key))
- {
- if (key != Keys.None)
- this.Events.Control_KeyPressed.Raise(new EventArgsKeyPressed(key));
- }
- else if (button.TryGetController(out Buttons controllerButton))
- {
- if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger)
- this.Events.Control_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.ControllerState.Triggers.Left : inputState.ControllerState.Triggers.Right));
- else
- this.Events.Control_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton));
- }
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Events: mouse wheel scrolled to {now}.", LogLevel.Trace);
+ this.Events.Input_MouseWheelScrolled.Raise(new InputMouseWheelScrolledEventArgs(cursor, was, now));
}
- else if (status == InputStatus.Released)
+
+ // raise input button events
+ foreach (var pair in inputState.ActiveButtons)
{
- this.Events.Input_ButtonReleased.Raise(new EventArgsInput(button, cursor, button.IsActionButton(), button.IsUseToolButton()));
+ SButton button = pair.Key;
+ InputStatus status = pair.Value;
- // legacy events
- if (button.TryGetKeyboard(out Keys key))
+ if (status == InputStatus.Pressed)
{
- if (key != Keys.None)
- this.Events.Control_KeyReleased.Raise(new EventArgsKeyPressed(key));
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace);
+
+ this.Events.Input_ButtonPressed.Raise(new InputButtonPressedEventArgs(button, cursor, inputState));
+ this.Events.Legacy_Input_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons));
+
+ // legacy events
+ if (button.TryGetKeyboard(out Keys key))
+ {
+ if (key != Keys.None)
+ this.Events.Legacy_Control_KeyPressed.Raise(new EventArgsKeyPressed(key));
+ }
+ else if (button.TryGetController(out Buttons controllerButton))
+ {
+ if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger)
+ this.Events.Legacy_Control_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right));
+ else
+ this.Events.Legacy_Control_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton));
+ }
}
- else if (button.TryGetController(out Buttons controllerButton))
+ else if (status == InputStatus.Released)
{
- if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger)
- this.Events.Control_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.ControllerState.Triggers.Left : inputState.ControllerState.Triggers.Right));
- else
- this.Events.Control_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton));
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace);
+
+ this.Events.Input_ButtonReleased.Raise(new InputButtonReleasedEventArgs(button, cursor, inputState));
+ this.Events.Legacy_Input_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons));
+
+ // legacy events
+ if (button.TryGetKeyboard(out Keys key))
+ {
+ if (key != Keys.None)
+ this.Events.Legacy_Control_KeyReleased.Raise(new EventArgsKeyPressed(key));
+ }
+ else if (button.TryGetController(out Buttons controllerButton))
+ {
+ if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger)
+ this.Events.Legacy_Control_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right));
+ else
+ this.Events.Legacy_Control_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton));
+ }
}
}
- }
-
- // raise legacy state-changed events
- if (inputState.KeyboardState != this.PreviousInput.KeyboardState)
- this.Events.Control_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(this.PreviousInput.KeyboardState, inputState.KeyboardState));
- if (inputState.MouseState != this.PreviousInput.MouseState)
- this.Events.Control_MouseChanged.Raise(new EventArgsMouseStateChanged(this.PreviousInput.MouseState, inputState.MouseState, this.PreviousInput.MousePosition, inputState.MousePosition));
- // track state
- this.PreviousInput = inputState;
+ // raise legacy state-changed events
+ if (inputState.RealKeyboard != previousInputState.RealKeyboard)
+ this.Events.Legacy_Control_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard));
+ if (inputState.RealMouse != previousInputState.RealMouse)
+ this.Events.Legacy_Control_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y)));
+ }
}
/*********
** Menu events
*********/
- if (Game1.activeClickableMenu != this.PreviousActiveMenu)
+ if (this.Watchers.ActiveMenuWatcher.IsChanged)
{
- IClickableMenu previousMenu = this.PreviousActiveMenu;
- IClickableMenu newMenu = Game1.activeClickableMenu;
+ IClickableMenu was = this.Watchers.ActiveMenuWatcher.PreviousValue;
+ IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue;
+ this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards
- // log context
if (this.VerboseLogging)
- {
- if (previousMenu == null)
- this.Monitor.Log($"Context: opened menu {newMenu?.GetType().FullName ?? "(none)"}.", LogLevel.Trace);
- else if (newMenu == null)
- this.Monitor.Log($"Context: closed menu {previousMenu.GetType().FullName}.", LogLevel.Trace);
- else
- this.Monitor.Log($"Context: changed menu from {previousMenu.GetType().FullName} to {newMenu.GetType().FullName}.", LogLevel.Trace);
- }
+ this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace);
// raise menu events
- if (newMenu != null)
- this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(previousMenu, newMenu));
+ if (now != null)
+ this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(was, now));
else
- this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(previousMenu));
-
- // update previous menu
- // (if the menu was changed in one of the handlers, deliberately defer detection until the next update so mods can be notified of the new menu change)
- this.PreviousActiveMenu = newMenu;
+ this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(was));
}
/*********
@@ -516,97 +529,190 @@ namespace StardewModdingAPI.Framework
*********/
if (Context.IsWorldReady)
{
- // raise current location changed
- // ReSharper disable once PossibleUnintendedReferenceComparison
- if (Game1.currentLocation != this.PreviousGameLocation)
+ bool raiseWorldEvents = !this.Watchers.SaveIdWatcher.IsChanged; // don't report changes from unloaded => loaded
+
+ // raise location changes
+ if (this.Watchers.LocationsWatcher.IsChanged)
{
- if (this.VerboseLogging)
- this.Monitor.Log($"Context: set location to {Game1.currentLocation?.Name ?? "(none)"}.", LogLevel.Trace);
- this.Events.Location_CurrentLocationChanged.Raise(new EventArgsCurrentLocationChanged(this.PreviousGameLocation, Game1.currentLocation));
+ // location list changes
+ if (this.Watchers.LocationsWatcher.IsLocationListChanged)
+ {
+ GameLocation[] added = this.Watchers.LocationsWatcher.Added.ToArray();
+ GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray();
+ this.Watchers.LocationsWatcher.ResetLocationList();
+
+ if (this.VerboseLogging)
+ {
+ string addedText = this.Watchers.LocationsWatcher.Added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none";
+ string removedText = this.Watchers.LocationsWatcher.Removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none";
+ this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).", LogLevel.Trace);
+ }
+
+ this.Events.World_LocationListChanged.Raise(new WorldLocationListChangedEventArgs(added, removed));
+ this.Events.Legacy_Location_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed));
+ }
+
+ // raise location contents changed
+ if (raiseWorldEvents)
+ {
+ foreach (LocationTracker watcher in this.Watchers.LocationsWatcher.Locations)
+ {
+ // buildings changed
+ if (watcher.BuildingsWatcher.IsChanged)
+ {
+ GameLocation location = watcher.Location;
+ Building[] added = watcher.BuildingsWatcher.Added.ToArray();
+ Building[] removed = watcher.BuildingsWatcher.Removed.ToArray();
+ watcher.BuildingsWatcher.Reset();
+
+ this.Events.World_BuildingListChanged.Raise(new WorldBuildingListChangedEventArgs(location, added, removed));
+ this.Events.Legacy_Location_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed));
+ }
+
+ // debris changed
+ if (watcher.DebrisWatcher.IsChanged)
+ {
+ GameLocation location = watcher.Location;
+ Debris[] added = watcher.DebrisWatcher.Added.ToArray();
+ Debris[] removed = watcher.DebrisWatcher.Removed.ToArray();
+ watcher.DebrisWatcher.Reset();
+
+ this.Events.World_DebrisListChanged.Raise(new WorldDebrisListChangedEventArgs(location, added, removed));
+ }
+
+ // large terrain features changed
+ if (watcher.LargeTerrainFeaturesWatcher.IsChanged)
+ {
+ GameLocation location = watcher.Location;
+ LargeTerrainFeature[] added = watcher.LargeTerrainFeaturesWatcher.Added.ToArray();
+ LargeTerrainFeature[] removed = watcher.LargeTerrainFeaturesWatcher.Removed.ToArray();
+ watcher.LargeTerrainFeaturesWatcher.Reset();
+
+ this.Events.World_LargeTerrainFeatureListChanged.Raise(new WorldLargeTerrainFeatureListChangedEventArgs(location, added, removed));
+ }
+
+ // NPCs changed
+ if (watcher.NpcsWatcher.IsChanged)
+ {
+ GameLocation location = watcher.Location;
+ NPC[] added = watcher.NpcsWatcher.Added.ToArray();
+ NPC[] removed = watcher.NpcsWatcher.Removed.ToArray();
+ watcher.NpcsWatcher.Reset();
+
+ this.Events.World_NpcListChanged.Raise(new WorldNpcListChangedEventArgs(location, added, removed));
+ }
+
+ // objects changed
+ if (watcher.ObjectsWatcher.IsChanged)
+ {
+ GameLocation location = watcher.Location;
+ KeyValuePair<Vector2, Object>[] added = watcher.ObjectsWatcher.Added.ToArray();
+ KeyValuePair<Vector2, Object>[] removed = watcher.ObjectsWatcher.Removed.ToArray();
+ watcher.ObjectsWatcher.Reset();
+
+ this.Events.World_ObjectListChanged.Raise(new WorldObjectListChangedEventArgs(location, added, removed));
+ this.Events.Legacy_Location_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed));
+ }
+
+ // terrain features changed
+ if (watcher.TerrainFeaturesWatcher.IsChanged)
+ {
+ GameLocation location = watcher.Location;
+ KeyValuePair<Vector2, TerrainFeature>[] added = watcher.TerrainFeaturesWatcher.Added.ToArray();
+ KeyValuePair<Vector2, TerrainFeature>[] removed = watcher.TerrainFeaturesWatcher.Removed.ToArray();
+ watcher.TerrainFeaturesWatcher.Reset();
+
+ this.Events.World_TerrainFeatureListChanged.Raise(new WorldTerrainFeatureListChangedEventArgs(location, added, removed));
+ }
+ }
+ }
+ else
+ this.Watchers.LocationsWatcher.Reset();
}
- // raise location list changed
- if (this.GetHash(Game1.locations) != this.PreviousGameLocations)
- this.Events.Location_LocationsChanged.Raise(new EventArgsGameLocationsChanged(Game1.locations));
+ // raise time changed
+ if (raiseWorldEvents && this.Watchers.TimeWatcher.IsChanged)
+ {
+ int was = this.Watchers.TimeWatcher.PreviousValue;
+ int now = this.Watchers.TimeWatcher.CurrentValue;
+ this.Watchers.TimeWatcher.Reset();
+
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Events: time changed from {was} to {now}.", LogLevel.Trace);
- // raise events that shouldn't be triggered on initial load
- if (Game1.uniqueIDForThisGame == this.PreviousSaveID)
+ this.Events.Time_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now));
+ }
+ else
+ this.Watchers.TimeWatcher.Reset();
+
+ // raise player events
+ if (raiseWorldEvents)
{
+ PlayerTracker curPlayer = this.Watchers.CurrentPlayerTracker;
+
+ // raise current location changed
+ if (curPlayer.TryGetNewLocation(out GameLocation newLocation))
+ {
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Context: set location to {newLocation.Name}.", LogLevel.Trace);
+ this.Events.Player_Warped.Raise(new EventArgsPlayerWarped(curPlayer.LocationWatcher.PreviousValue, newLocation));
+ }
+
// raise player leveled up a skill
- if (Game1.player.combatLevel != this.PreviousCombatLevel)
- this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel));
- if (Game1.player.farmingLevel != this.PreviousFarmingLevel)
- this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel));
- if (Game1.player.fishingLevel != this.PreviousFishingLevel)
- this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel));
- if (Game1.player.foragingLevel != this.PreviousForagingLevel)
- this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel));
- if (Game1.player.miningLevel != this.PreviousMiningLevel)
- this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel));
- if (Game1.player.luckLevel != this.PreviousLuckLevel)
- this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel));
+ foreach (KeyValuePair<EventArgsLevelUp.LevelType, IValueWatcher<int>> pair in curPlayer.GetChangedSkills())
+ {
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.PreviousValue} to {pair.Value.CurrentValue}.", LogLevel.Trace);
+ this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(pair.Key, pair.Value.CurrentValue));
+ }
// raise player inventory changed
- ItemStackChange[] changedItems = this.GetInventoryChanges(Game1.player.items, this.PreviousItems).ToArray();
+ ItemStackChange[] changedItems = curPlayer.GetInventoryChanges().ToArray();
if (changedItems.Any())
- this.Events.Player_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.items, changedItems.ToList()));
-
- // raise current location's object list changed
- if (this.GetHash(Game1.currentLocation.objects) != this.PreviousLocationObjects)
- this.Events.Location_LocationObjectsChanged.Raise(new EventArgsLocationObjectsChanged(
-#if STARDEW_VALLEY_1_3
- Game1.currentLocation.objects.FieldDict
-#else
- Game1.currentLocation.objects
-#endif
- ));
-
- // raise time changed
- if (Game1.timeOfDay != this.PreviousTime)
- this.Events.Time_TimeOfDayChanged.Raise(new EventArgsIntChanged(this.PreviousTime, Game1.timeOfDay));
+ {
+ if (this.VerboseLogging)
+ this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace);
+ this.Events.Player_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems.ToList()));
+ }
// raise mine level changed
- if (Game1.mine != null && Game1.mine.mineLevel != this.PreviousMineLevel)
- this.Events.Mine_LevelChanged.Raise(new EventArgsMineLevelChanged(this.PreviousMineLevel, Game1.mine.mineLevel));
+ if (curPlayer.TryGetNewMineLevel(out int mineLevel))
+ {
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Context: mine level changed to {mineLevel}.", LogLevel.Trace);
+ this.Events.Mine_LevelChanged.Raise(new EventArgsMineLevelChanged(curPlayer.MineLevelWatcher.PreviousValue, mineLevel));
+ }
}
-
- // update state
- this.PreviousGameLocations = this.GetHash(Game1.locations);
- this.PreviousGameLocation = Game1.currentLocation;
- this.PreviousCombatLevel = Game1.player.combatLevel;
- this.PreviousFarmingLevel = Game1.player.farmingLevel;
- this.PreviousFishingLevel = Game1.player.fishingLevel;
- this.PreviousForagingLevel = Game1.player.foragingLevel;
- this.PreviousMiningLevel = Game1.player.miningLevel;
- this.PreviousLuckLevel = Game1.player.luckLevel;
- this.PreviousItems = Game1.player.items.Where(n => n != null).Distinct().ToDictionary(n => n, n => n.Stack);
- this.PreviousLocationObjects = this.GetHash(Game1.currentLocation.objects);
- this.PreviousTime = Game1.timeOfDay;
- this.PreviousMineLevel = Game1.mine?.mineLevel ?? 0;
- this.PreviousSaveID = Game1.uniqueIDForThisGame;
+ this.Watchers.CurrentPlayerTracker?.Reset();
}
+ // update save ID watcher
+ this.Watchers.SaveIdWatcher.Reset();
+
/*********
** Game update
*********/
+ this.TicksElapsed++;
+ if (this.TicksElapsed == 1)
+ this.Events.GameLoop_Launched.Raise(new GameLoopLaunchedEventArgs());
+ this.Events.GameLoop_Updating.Raise(new GameLoopUpdatingEventArgs(this.TicksElapsed));
try
{
+ this.Input.UpdateSuppression();
base.Update(gameTime);
}
catch (Exception ex)
{
this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error);
}
+ this.Events.GameLoop_Updated.Raise(new GameLoopUpdatedEventArgs(this.TicksElapsed));
/*********
** Update events
*********/
this.Events.Specialised_UnvalidatedUpdateTick.Raise();
- if (this.FirstUpdate)
- {
- this.FirstUpdate = false;
+ if (this.TicksElapsed == 1)
this.Events.Game_FirstUpdateTick.Raise();
- }
this.Events.Game_UpdateTick.Raise();
if (this.CurrentUpdateTick % 2 == 0)
this.Events.Game_SecondUpdateTick.Raise();
@@ -662,7 +768,7 @@ namespace StardewModdingAPI.Framework
// recover sprite batch
try
{
- if (Game1.spriteBatch.IsOpen(SGame.Reflection))
+ if (Game1.spriteBatch.IsOpen(this.Reflection))
{
this.Monitor.Log("Recovering sprite batch from error...", LogLevel.Trace);
Game1.spriteBatch.End();
@@ -686,7 +792,8 @@ namespace StardewModdingAPI.Framework
[SuppressMessage("ReSharper", "RedundantCast", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")]
-#if STARDEW_VALLEY_1_3
+ [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")]
+ [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")]
private void DrawImpl(GameTime gameTime)
{
if (Game1.debugMode)
@@ -770,6 +877,7 @@ namespace StardewModdingAPI.Framework
}
this.RaisePostRender();
Game1.spriteBatch.End();
+ this.drawOverlays(Game1.spriteBatch);
if ((double)Game1.options.zoomLevel != 1.0)
{
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
@@ -778,9 +886,8 @@ namespace StardewModdingAPI.Framework
Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
- this.drawOverlays(Game1.spriteBatch);
}
- else if ((int)Game1.gameMode == 11)
+ else if (Game1.gameMode == (byte)11)
{
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink);
@@ -795,9 +902,10 @@ namespace StardewModdingAPI.Framework
if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause))
{
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha));
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha));
Game1.spriteBatch.End();
}
+ this.drawOverlays(Game1.spriteBatch);
this.RaisePostRender(needsNewBatch: true);
if ((double)Game1.options.zoomLevel != 1.0)
{
@@ -807,7 +915,6 @@ namespace StardewModdingAPI.Framework
Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
- this.drawOverlays(Game1.spriteBatch);
}
else if (Game1.showingEndOfNightStuff)
{
@@ -828,694 +935,46 @@ namespace StardewModdingAPI.Framework
}
this.RaisePostRender();
Game1.spriteBatch.End();
- if ((double)Game1.options.zoomLevel != 1.0)
- {
- this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
- this.GraphicsDevice.Clear(this.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
this.drawOverlays(Game1.spriteBatch);
- }
- else
- {
- int num1;
- switch (Game1.gameMode)
- {
- case 3:
- num1 = Game1.currentLocation == null ? 1 : 0;
- break;
- case 6:
- num1 = 1;
- break;
- default:
- num1 = 0;
- break;
- }
- if (num1 != 0)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- string str1 = "";
- for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index)
- str1 += ".";
- string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688");
- string s = str2 + str1;
- string str3 = str2 + "... ";
- int widthOfString = SpriteText.getWidthOfString(str3);
- int height = 64;
- int x = 64;
- int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height;
- SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1);
- Game1.spriteBatch.End();
- if ((double)Game1.options.zoomLevel != 1.0)
- {
- this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
- this.GraphicsDevice.Clear(this.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- this.drawOverlays(Game1.spriteBatch);
- //base.Draw(gameTime);
- }
- else
- {
- Viewport viewport1;
- if ((int)Game1.gameMode == 0)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- }
- else
- {
- Microsoft.Xna.Framework.Rectangle bounds;
- if (Game1.drawLighting)
- {
- this.GraphicsDevice.SetRenderTarget(Game1.lightmap);
- this.GraphicsDevice.Clear(Color.White * 0.0f);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.Name.StartsWith("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight));
- for (int index = 0; index < Game1.currentLightSources.Count; ++index)
- {
- if (Utility.isOnScreen((Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position), (int)((double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) * 64.0 * 4.0)))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D lightTexture = Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture;
- Vector2 position = Game1.GlobalToLocal(Game1.viewport, (Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position)) / (float)(Game1.options.lightingQuality / 2);
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds);
- Color color = (Color)((NetFieldBase<Color, NetColor>)Game1.currentLightSources.ElementAt<LightSource>(index).color);
- double num2 = 0.0;
- bounds = Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds;
- double x = (double)bounds.Center.X;
- bounds = Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds;
- double y = (double)bounds.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num3 = (double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) / (double)(Game1.options.lightingQuality / 2);
- int num4 = 0;
- double num5 = 0.899999976158142;
- spriteBatch.Draw(lightTexture, position, sourceRectangle, color, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
- }
- }
- Game1.spriteBatch.End();
- this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screen);
- }
- if (Game1.bloomDay && Game1.bloom != null)
- Game1.bloom.BeginDraw();
- this.GraphicsDevice.Clear(this.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- this.Events.Graphics_OnPreRenderEvent.Raise();
- if (Game1.background != null)
- Game1.background.draw(Game1.spriteBatch);
- Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
- Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
- Game1.currentLocation.drawWater(Game1.spriteBatch);
- if (Game1.CurrentEvent == null)
- {
- foreach (NPC character in Game1.currentLocation.characters)
- {
- if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && !character.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12))));
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
- Color white = Color.White;
- double num2 = 0.0;
- bounds = Game1.shadowTexture.Bounds;
- double x = (double)bounds.Center.X;
- bounds = Game1.shadowTexture.Bounds;
- double y = (double)bounds.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num3 = (4.0 + (double)character.yJumpOffset / 40.0) * (double)(float)((NetFieldBase<float, NetFloat>)character.scale);
- int num4 = 0;
- double num5 = (double)Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 9.99999997475243E-07;
- spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
- }
- }
- }
- else
- {
- foreach (NPC actor in Game1.CurrentEvent.actors)
- {
- if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation()))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12)))));
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
- Color white = Color.White;
- double num2 = 0.0;
- bounds = Game1.shadowTexture.Bounds;
- double x = (double)bounds.Center.X;
- bounds = Game1.shadowTexture.Bounds;
- double y = (double)bounds.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num3 = (4.0 + (double)actor.yJumpOffset / 40.0) * (double)(float)((NetFieldBase<float, NetFloat>)actor.scale);
- int num4 = 0;
- double num5 = (double)Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 9.99999997475243E-07;
- spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
- }
- }
- }
- foreach (SFarmer farmer in Game1.currentLocation.getFarmers())
- {
- if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation())))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f));
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
- Color white = Color.White;
- double num2 = 0.0;
- Microsoft.Xna.Framework.Rectangle bounds2 = Game1.shadowTexture.Bounds;
- double x = (double)bounds2.Center.X;
- bounds2 = Game1.shadowTexture.Bounds;
- double y = (double)bounds2.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num3 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5);
- int num4 = 0;
- double num5 = 0.0;
- spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
- }
- }
- Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
- Game1.mapDisplayDevice.EndScene();
- Game1.spriteBatch.End();
- Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.CurrentEvent == null)
- {
- foreach (NPC character in Game1.currentLocation.characters)
- {
- if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f);
- }
- }
- else
- {
- foreach (NPC actor in Game1.CurrentEvent.actors)
- {
- if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation()))
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f);
- }
- }
- foreach (SFarmer farmer in Game1.currentLocation.getFarmers())
- {
- if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation())))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f));
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
- Color white = Color.White;
- double num2 = 0.0;
- Microsoft.Xna.Framework.Rectangle bounds2 = Game1.shadowTexture.Bounds;
- double x = (double)bounds2.Center.X;
- bounds2 = Game1.shadowTexture.Bounds;
- double y = (double)bounds2.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num3 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5);
- int num4 = 0;
- double num5 = 0.0;
- spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
- }
- }
- if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null))
- Game1.currentLocation.currentEvent.draw(Game1.spriteBatch);
- if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm"))
- Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0));
- Game1.currentLocation.draw(Game1.spriteBatch);
- if (!Game1.eventUp || Game1.currentLocation.currentEvent == null || Game1.currentLocation.currentEvent.messageToScreen == null)
- ;
- if (Game1.player.ActiveObject == null && ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool)))
- Game1.drawTool(Game1.player);
- if (Game1.currentLocation.Name.Equals("Farm"))
- this.drawFarmBuildings();
- if (Game1.tvStation >= 0)
- Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f);
- if (Game1.panMode)
- {
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Color.Lime * 0.75f);
- foreach (Warp warp in (NetList<Warp, NetRef<Warp>>)Game1.currentLocation.warps)
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Color.Red * 0.75f);
- }
- Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
- Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
- Game1.mapDisplayDevice.EndScene();
- Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch);
- Game1.spriteBatch.End();
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.player.ActiveObject.bigCraftable) && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null)
- Game1.drawPlayerHeldObject(Game1.player);
- else if (Game1.displayFarmer && Game1.player.ActiveObject != null && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways") || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")))
- Game1.drawPlayerHeldObject(Game1.player);
- if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null)
- Game1.drawTool(Game1.player);
- if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null)
- {
- Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
- Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
- Game1.mapDisplayDevice.EndScene();
- }
- if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool)
- {
- Color color = Color.White;
- switch ((int)((double)Game1.toolHold / 600.0) + 2)
- {
- case 1:
- color = Tool.copperColor;
- break;
- case 2:
- color = Tool.steelColor;
- break;
- case 3:
- color = Tool.goldColor;
- break;
- case 4:
- color = Tool.iridiumColor;
- break;
- }
- Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Color.Black);
- Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), 8), color);
- }
- if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!(bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.ignoreDebrisWeather) && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10)
- {
- foreach (WeatherDebris weatherDebris in Game1.debrisWeather)
- weatherDebris.draw(Game1.spriteBatch);
- }
- if (Game1.farmEvent != null)
- Game1.farmEvent.draw(Game1.spriteBatch);
- if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000)
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel);
- if (Game1.screenGlow)
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha);
- Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch);
- if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || (Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))
- Game1.player.CurrentTool.draw(Game1.spriteBatch);
- if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / 64), (float)(Game1.viewport.Y / 64)))))
- {
- for (int index = 0; index < Game1.rainDrops.Length; ++index)
- Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White);
- }
- Game1.spriteBatch.End();
- Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.eventUp && Game1.currentLocation.currentEvent != null)
- {
- foreach (NPC actor in Game1.currentLocation.currentEvent.actors)
- {
- if (actor.isEmoting)
- {
- Vector2 localPosition = actor.getLocalPosition(Game1.viewport);
- localPosition.Y -= 140f;
- if (actor.Age == 2)
- localPosition.Y += 32f;
- else if (actor.Gender == 1)
- localPosition.Y += 10f;
- Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f);
- }
- }
- }
- Game1.spriteBatch.End();
- if (Game1.drawLighting)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f);
- if (Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert))
- Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f);
- Game1.spriteBatch.End();
- }
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.drawGrid)
- {
- int num2 = -Game1.viewport.X % 64;
- float num3 = (float)(-Game1.viewport.Y % 64);
- int num4 = num2;
- while (true)
- {
- int num5 = num4;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- int width1 = viewport1.Width;
- if (num5 < width1)
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D staminaRect = Game1.staminaRect;
- int x = num4;
- int y = (int)num3;
- int width2 = 1;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- int height = viewport1.Height;
- Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width2, height);
- Color color = Color.Red * 0.5f;
- spriteBatch.Draw(staminaRect, destinationRectangle, color);
- num4 += 64;
- }
- else
- break;
- }
- float num6 = num3;
- while (true)
- {
- double num5 = (double)num6;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- double height1 = (double)viewport1.Height;
- if (num5 < height1)
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D staminaRect = Game1.staminaRect;
- int x = num2;
- int y = (int)num6;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- int width = viewport1.Width;
- int height2 = 1;
- Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, height2);
- Color color = Color.Red * 0.5f;
- spriteBatch.Draw(staminaRect, destinationRectangle, color);
- num6 += 64f;
- }
- else
- break;
- }
- }
- if ((uint)Game1.currentBillboard > 0U)
- this.drawBillboard();
- if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && (int)Game1.gameMode == 3) && (!Game1.freezeControls && !Game1.panMode) && !Game1.HostPaused)
- {
- this.Events.Graphics_OnPreRenderHudEvent.Raise();
- this.drawHUD();
- this.Events.Graphics_OnPostRenderHudEvent.Raise();
- }
- else if (Game1.activeClickableMenu == null && Game1.farmEvent == null)
- Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2((float)Game1.getOldMouseX(), (float)Game1.getOldMouseY()), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16)), Color.White, 0.0f, Vector2.Zero, (float)(4.0 + (double)Game1.dialogueButtonScale / 150.0), SpriteEffects.None, 1f);
- if (Game1.hudMessages.Count > 0 && (!Game1.eventUp || Game1.isFestival()))
- {
- for (int i = Game1.hudMessages.Count - 1; i >= 0; --i)
- Game1.hudMessages[i].draw(Game1.spriteBatch, i);
- }
- }
- if (Game1.farmEvent != null)
- Game1.farmEvent.draw(Game1.spriteBatch);
- if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)))
- this.drawDialogueBox();
- if (Game1.progressBar)
- {
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, Game1.dialogueWidth, 32), Color.LightGray);
- Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth), 32), Color.DimGray);
- }
- if (Game1.eventUp && (Game1.currentLocation != null && Game1.currentLocation.currentEvent != null))
- Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch);
- if (Game1.isRaining && (Game1.currentLocation != null && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert)))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D staminaRect = Game1.staminaRect;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle bounds = viewport1.Bounds;
- Color color = Color.Blue * 0.2f;
- spriteBatch.Draw(staminaRect, bounds, color);
- }
- if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D fadeToBlackRect = Game1.fadeToBlackRect;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle bounds = viewport1.Bounds;
- Color color = Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha);
- spriteBatch.Draw(fadeToBlackRect, bounds, color);
- }
- else if ((double)Game1.flashAlpha > 0.0)
- {
- if (Game1.options.screenFlash)
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D fadeToBlackRect = Game1.fadeToBlackRect;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle bounds = viewport1.Bounds;
- Color color = Color.White * Math.Min(1f, Game1.flashAlpha);
- spriteBatch.Draw(fadeToBlackRect, bounds, color);
- }
- Game1.flashAlpha -= 0.1f;
- }
- if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp)
- this.drawDialogueBox();
- foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites)
- overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f);
- if (Game1.debugMode)
- {
- StringBuilder debugStringBuilder = SGame._debugStringBuilder;
- debugStringBuilder.Clear();
- if (Game1.panMode)
- {
- debugStringBuilder.Append((Game1.getOldMouseX() + Game1.viewport.X) / 64);
- debugStringBuilder.Append(",");
- debugStringBuilder.Append((Game1.getOldMouseY() + Game1.viewport.Y) / 64);
- }
- else
- {
- debugStringBuilder.Append("player: ");
- debugStringBuilder.Append(Game1.player.getStandingX() / 64);
- debugStringBuilder.Append(", ");
- debugStringBuilder.Append(Game1.player.getStandingY() / 64);
- }
- debugStringBuilder.Append(" mouseTransparency: ");
- debugStringBuilder.Append(Game1.mouseCursorTransparency);
- debugStringBuilder.Append(" mousePosition: ");
- debugStringBuilder.Append(Game1.getMouseX());
- debugStringBuilder.Append(",");
- debugStringBuilder.Append(Game1.getMouseY());
- debugStringBuilder.Append(Environment.NewLine);
- debugStringBuilder.Append("debugOutput: ");
- debugStringBuilder.Append(Game1.debugOutput);
- Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
- }
- if (Game1.showKeyHelp)
- Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
- if (Game1.activeClickableMenu != null)
- {
- try
- {
- this.Events.Graphics_OnPreRenderGuiEvent.Raise();
- Game1.activeClickableMenu.draw(Game1.spriteBatch);
- this.Events.Graphics_OnPostRenderGuiEvent.Raise();
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
- Game1.activeClickableMenu.exitThisMenu();
- }
- }
- else if (Game1.farmEvent != null)
- Game1.farmEvent.drawAboveEverything(Game1.spriteBatch);
- if (Game1.HostPaused)
- {
- string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378");
- SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1);
- }
- this.RaisePostRender();
- Game1.spriteBatch.End();
- this.drawOverlays(Game1.spriteBatch);
- this.renderScreenBuffer();
- //base.Draw(gameTime);
- }
- }
- }
- }
- }
-#else
- private void DrawImpl(GameTime gameTime)
- {
- if (Game1.debugMode)
- {
- if (SGame._fpsStopwatch.IsRunning)
- {
- float totalSeconds = (float)SGame._fpsStopwatch.Elapsed.TotalSeconds;
- SGame._fpsList.Add(totalSeconds);
- while (SGame._fpsList.Count >= 120)
- SGame._fpsList.RemoveAt(0);
- float num = 0.0f;
- foreach (float fps in SGame._fpsList)
- num += fps;
- SGame._fps = (float)(1.0 / ((double)num / (double)SGame._fpsList.Count));
- }
- SGame._fpsStopwatch.Restart();
- }
- else
- {
- if (SGame._fpsStopwatch.IsRunning)
- SGame._fpsStopwatch.Reset();
- SGame._fps = 0.0f;
- SGame._fpsList.Clear();
- }
- if (SGame._newDayTask != null)
- {
- this.GraphicsDevice.Clear(this.bgColor);
- //base.Draw(gameTime);
- }
- else
- {
- if ((double)Game1.options.zoomLevel != 1.0)
- this.GraphicsDevice.SetRenderTarget(this.screenWrapper);
- if (this.IsSaving)
- {
- this.GraphicsDevice.Clear(this.bgColor);
- IClickableMenu activeClickableMenu = Game1.activeClickableMenu;
- if (activeClickableMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- try
- {
- this.Events.Graphics_OnPreRenderGuiEvent.Raise();
- activeClickableMenu.draw(Game1.spriteBatch);
- this.Events.Graphics_OnPostRenderGuiEvent.Raise();
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
- activeClickableMenu.exitThisMenu();
- }
- this.RaisePostRender();
- Game1.spriteBatch.End();
- }
- //base.Draw(gameTime);
- this.renderScreenBuffer();
- }
- else
- {
- this.GraphicsDevice.Clear(this.bgColor);
- if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet())
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- try
- {
- Game1.activeClickableMenu.drawBackground(Game1.spriteBatch);
- this.Events.Graphics_OnPreRenderGuiEvent.Raise();
- Game1.activeClickableMenu.draw(Game1.spriteBatch);
- this.Events.Graphics_OnPostRenderGuiEvent.Raise();
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
- Game1.activeClickableMenu.exitThisMenu();
- }
- this.RaisePostRender();
- Game1.spriteBatch.End();
- if ((double)Game1.options.zoomLevel != 1.0)
- {
- this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
- this.GraphicsDevice.Clear(this.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- if (Game1.overlayMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
- Game1.spriteBatch.End();
- }
- }
- else if ((int)Game1.gameMode == 11)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink);
- Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0));
- Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White);
- this.RaisePostRender();
- Game1.spriteBatch.End();
- }
- else if (Game1.currentMinigame != null)
- {
- Game1.currentMinigame.draw(Game1.spriteBatch);
- if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause))
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha));
- Game1.spriteBatch.End();
- }
- this.RaisePostRender(needsNewBatch: true);
- if ((double)Game1.options.zoomLevel != 1.0)
- {
- this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
- this.GraphicsDevice.Clear(this.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- if (Game1.overlayMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
- Game1.spriteBatch.End();
- }
- }
- else if (Game1.showingEndOfNightStuff)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.activeClickableMenu != null)
- {
- try
- {
- this.Events.Graphics_OnPreRenderGuiEvent.Raise();
- Game1.activeClickableMenu.draw(Game1.spriteBatch);
- this.Events.Graphics_OnPostRenderGuiEvent.Raise();
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself during end-of-night-stuff. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
- Game1.activeClickableMenu.exitThisMenu();
- }
- }
- this.RaisePostRender();
- Game1.spriteBatch.End();
if ((double)Game1.options.zoomLevel != 1.0)
{
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
this.GraphicsDevice.Clear(this.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- if (Game1.overlayMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
+ Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
}
- else if ((int)Game1.gameMode == 6)
+ else if (Game1.gameMode == (byte)6 || Game1.gameMode == (byte)3 && Game1.currentLocation == null)
{
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
string str1 = "";
for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index)
str1 += ".";
string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688");
- string str3 = str1;
- string s = str2 + str3;
- string str4 = "...";
- string str5 = str2 + str4;
- int widthOfString = SpriteText.getWidthOfString(str5);
+ string s = str2 + str1;
+ string str3 = str2 + "... ";
+ int widthOfString = SpriteText.getWidthOfString(str3);
int height = 64;
int x = 64;
- int y = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - height;
- SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str5, -1);
- this.RaisePostRender();
+ int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height;
+ SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1);
Game1.spriteBatch.End();
+ this.drawOverlays(Game1.spriteBatch);
if ((double)Game1.options.zoomLevel != 1.0)
{
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
this.GraphicsDevice.Clear(this.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- if (Game1.overlayMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
+ Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
+ //base.Draw(gameTime);
}
else
{
Microsoft.Xna.Framework.Rectangle rectangle;
- if ((int)Game1.gameMode == 0)
+ if (Game1.gameMode == (byte)0)
{
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
}
@@ -1526,14 +985,14 @@ namespace StardewModdingAPI.Framework
this.GraphicsDevice.SetRenderTarget(Game1.lightmap);
this.GraphicsDevice.Clear(Color.White * 0.0f);
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.name.Equals("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && Game1.currentLocation.isOutdoors ? Game1.outdoorLight : Game1.ambientLight));
+ Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.Name.StartsWith("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight));
for (int index = 0; index < Game1.currentLightSources.Count; ++index)
{
- if (Utility.isOnScreen(Game1.currentLightSources.ElementAt<LightSource>(index).position, (int)((double)Game1.currentLightSources.ElementAt<LightSource>(index).radius * (double)Game1.tileSize * 4.0)))
- Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, Game1.currentLightSources.ElementAt<LightSource>(index).position) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), Game1.currentLightSources.ElementAt<LightSource>(index).color, 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), Game1.currentLightSources.ElementAt<LightSource>(index).radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f);
+ if (Utility.isOnScreen((Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position), (int)((double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) * 64.0 * 4.0)))
+ Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position)) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), (Color)((NetFieldBase<Color, NetColor>)Game1.currentLightSources.ElementAt<LightSource>(index).color), 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), (float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f);
}
Game1.spriteBatch.End();
- this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screenWrapper);
+ this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screen);
}
if (Game1.bloomDay && Game1.bloom != null)
Game1.bloom.BeginDraw();
@@ -1543,117 +1002,105 @@ namespace StardewModdingAPI.Framework
if (Game1.background != null)
Game1.background.draw(Game1.spriteBatch);
Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
- Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom);
+ Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
Game1.currentLocation.drawWater(Game1.spriteBatch);
- if (Game1.CurrentEvent == null)
+ IEnumerable<Farmer> source = Game1.currentLocation.farmers;
+ if (Game1.currentLocation.currentEvent != null && !Game1.currentLocation.currentEvent.isFestival && Game1.currentLocation.currentEvent.farmerActors.Count > 0)
+ source = (IEnumerable<Farmer>)Game1.currentLocation.currentEvent.farmerActors;
+ IEnumerable<Farmer> farmers = source.Where<Farmer>((Func<Farmer, bool>)(farmer =>
{
- foreach (NPC character in Game1.currentLocation.characters)
+ if (!farmer.IsLocalPlayer)
+ return !(bool)((NetFieldBase<bool, NetBool>)farmer.hidden);
+ return true;
+ }));
+ if (!Game1.currentLocation.shouldHideCharacters())
+ {
+ if (Game1.CurrentEvent == null)
{
- if (!character.swimming && !character.hideShadow && (!character.isInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation())))
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.position + new Vector2((float)(character.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : Game1.pixelZoom * 3)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), ((float)Game1.pixelZoom + (float)character.yJumpOffset / 40f) * character.scale, SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f);
+ foreach (NPC character in Game1.currentLocation.characters)
+ {
+ if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && (!character.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation())))
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f);
+ }
}
- }
- else
- {
- foreach (NPC actor in Game1.CurrentEvent.actors)
+ else
{
- if (!actor.swimming && !actor.hideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation()))
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.position + new Vector2((float)(actor.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.sprite.spriteHeight <= 16 ? -Game1.pixelZoom : Game1.pixelZoom * 3))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), ((float)Game1.pixelZoom + (float)actor.yJumpOffset / 40f) * actor.scale, SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f);
+ foreach (NPC actor in Game1.CurrentEvent.actors)
+ {
+ if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation()))
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f);
+ }
}
- }
- Microsoft.Xna.Framework.Rectangle bounds;
- if (Game1.displayFarmer && !Game1.player.swimming && (!Game1.player.isRidingHorse() && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation())))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f));
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
- Color white = Color.White;
- double num1 = 0.0;
- double x = (double)Game1.shadowTexture.Bounds.Center.X;
- bounds = Game1.shadowTexture.Bounds;
- double y = (double)bounds.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num2 = 4.0 - (!Game1.player.running && !Game1.player.usingTool || Game1.player.FarmerSprite.indexInCurrentAnimation <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5);
- int num3 = 0;
- double num4 = 0.0;
- spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4);
- }
- Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom);
- Game1.mapDisplayDevice.EndScene();
- Game1.spriteBatch.End();
- Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.CurrentEvent == null)
- {
- foreach (NPC character in Game1.currentLocation.characters)
+ foreach (Farmer farmer in farmers)
{
- if (!character.swimming && !character.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))
+ if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation())))
{
SpriteBatch spriteBatch = Game1.spriteBatch;
Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(Game1.viewport, character.position + new Vector2((float)(character.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : Game1.pixelZoom * 3))));
+ Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f));
Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
Color white = Color.White;
double num1 = 0.0;
- bounds = Game1.shadowTexture.Bounds;
+ Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds;
double x = (double)bounds.Center.X;
bounds = Game1.shadowTexture.Bounds;
double y = (double)bounds.Center.Y;
Vector2 origin = new Vector2((float)x, (float)y);
- double num2 = ((double)Game1.pixelZoom + (double)character.yJumpOffset / 40.0) * (double)character.scale;
+ double num2 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5);
int num3 = 0;
- double num4 = (double)Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 9.99999997475243E-07;
+ double num4 = 0.0;
spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4);
}
}
}
- else
+ Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
+ Game1.mapDisplayDevice.EndScene();
+ Game1.spriteBatch.End();
+ Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
+ if (!Game1.currentLocation.shouldHideCharacters())
{
- foreach (NPC actor in Game1.CurrentEvent.actors)
+ if (Game1.CurrentEvent == null)
+ {
+ foreach (NPC character in Game1.currentLocation.characters)
+ {
+ if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f);
+ }
+ }
+ else
+ {
+ foreach (NPC actor in Game1.CurrentEvent.actors)
+ {
+ if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation()))
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f);
+ }
+ }
+ foreach (Farmer farmer in farmers)
{
- if (!actor.swimming && !actor.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation()))
+ if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation())))
{
SpriteBatch spriteBatch = Game1.spriteBatch;
Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(Game1.viewport, actor.position + new Vector2((float)(actor.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : Game1.pixelZoom * 3))));
+ Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f));
Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
Color white = Color.White;
double num1 = 0.0;
- bounds = Game1.shadowTexture.Bounds;
+ Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds;
double x = (double)bounds.Center.X;
bounds = Game1.shadowTexture.Bounds;
double y = (double)bounds.Center.Y;
Vector2 origin = new Vector2((float)x, (float)y);
- double num2 = ((double)Game1.pixelZoom + (double)actor.yJumpOffset / 40.0) * (double)actor.scale;
+ double num2 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5);
int num3 = 0;
- double num4 = (double)Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 9.99999997475243E-07;
+ double num4 = 0.0;
spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4);
}
}
}
- if (Game1.displayFarmer && !Game1.player.swimming && (!Game1.player.isRidingHorse() && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation())))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D shadowTexture = Game1.shadowTexture;
- Vector2 local = Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f));
- Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
- Color white = Color.White;
- double num1 = 0.0;
- double x = (double)Game1.shadowTexture.Bounds.Center.X;
- rectangle = Game1.shadowTexture.Bounds;
- double y = (double)rectangle.Center.Y;
- Vector2 origin = new Vector2((float)x, (float)y);
- double num2 = 4.0 - (!Game1.player.running && !Game1.player.usingTool || Game1.player.FarmerSprite.indexInCurrentAnimation <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5);
- int num3 = 0;
- double num4 = (double)Math.Max(0.0001f, (float)((double)Game1.player.getStandingY() / 10000.0 + 0.000110000000859145)) - 9.99999974737875E-05;
- spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4);
- }
- if (Game1.displayFarmer)
- Game1.player.draw(Game1.spriteBatch);
if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null))
Game1.currentLocation.currentEvent.draw(Game1.spriteBatch);
if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm"))
- Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + (double)(Game1.tileSize * 3 / 4)) / 10000.0));
+ Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0));
Game1.currentLocation.draw(Game1.spriteBatch);
if (Game1.eventUp && Game1.currentLocation.currentEvent != null)
{
@@ -1664,60 +1111,50 @@ namespace StardewModdingAPI.Framework
if (Game1.currentLocation.Name.Equals("Farm"))
this.drawFarmBuildings();
if (Game1.tvStation >= 0)
- Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(6 * Game1.tileSize + Game1.tileSize / 4), (float)(2 * Game1.tileSize + Game1.tileSize / 2))), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f);
+ Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f);
if (Game1.panMode)
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Lime * 0.75f);
- foreach (Warp warp in Game1.currentLocation.warps)
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * Game1.tileSize - Game1.viewport.X, warp.Y * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Red * 0.75f);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Color.Lime * 0.75f);
+ foreach (Warp warp in (NetList<Warp, NetRef<Warp>>)Game1.currentLocation.warps)
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Color.Red * 0.75f);
}
Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
- Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom);
+ Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
Game1.mapDisplayDevice.EndScene();
Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch);
Game1.spriteBatch.End();
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- if (Game1.currentLocation.Name.Equals("Farm") && Game1.stats.SeedsSown >= 200U)
- {
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(3 * Game1.tileSize + Game1.tileSize / 4), (float)(Game1.tileSize + Game1.tileSize / 3))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize + Game1.tileSize), (float)(2 * Game1.tileSize + Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(5 * Game1.tileSize), (float)(2 * Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(3 * Game1.tileSize + Game1.tileSize / 2), (float)(3 * Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(5 * Game1.tileSize - Game1.tileSize / 4), (float)Game1.tileSize)), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize), (float)(3 * Game1.tileSize + Game1.tileSize / 6))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize + Game1.tileSize / 5), (float)(2 * Game1.tileSize + Game1.tileSize / 3))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White);
- }
- if (Game1.displayFarmer && Game1.player.ActiveObject != null && (Game1.player.ActiveObject.bigCraftable && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null)
+ if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.player.ActiveObject.bigCraftable) && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null)
Game1.drawPlayerHeldObject(Game1.player);
else if (Game1.displayFarmer && Game1.player.ActiveObject != null)
{
- if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways"))
+ if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways"))
{
Layer layer1 = Game1.currentLocation.Map.GetLayer("Front");
rectangle = Game1.player.GetBoundingBox();
- Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5);
+ Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38);
Size size1 = Game1.viewport.Size;
if (layer1.PickTile(mapDisplayLocation1, size1) != null)
{
Layer layer2 = Game1.currentLocation.Map.GetLayer("Front");
rectangle = Game1.player.GetBoundingBox();
- Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5);
+ Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38);
Size size2 = Game1.viewport.Size;
if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways"))
- goto label_127;
+ goto label_139;
}
else
- goto label_127;
+ goto label_139;
}
Game1.drawPlayerHeldObject(Game1.player);
}
- label_127:
- if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null)))
+ label_139:
+ if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null)))
Game1.drawTool(Game1.player);
if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null)
{
Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
- Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom);
+ Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
Game1.mapDisplayDevice.EndScene();
}
if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool)
@@ -1738,10 +1175,10 @@ namespace StardewModdingAPI.Framework
color = Tool.iridiumColor;
break;
}
- Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, Game1.tileSize / 8 + 4), Color.Black);
- Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), Game1.tileSize / 8), color);
+ Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Color.Black);
+ Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), 8), color);
}
- if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.ignoreDebrisWeather && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10)
+ if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!(bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.ignoreDebrisWeather) && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10)
{
foreach (WeatherDebris weatherDebris in Game1.debrisWeather)
weatherDebris.draw(Game1.spriteBatch);
@@ -1755,13 +1192,12 @@ namespace StardewModdingAPI.Framework
Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch);
if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure)))
Game1.player.CurrentTool.draw(Game1.spriteBatch);
- if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / Game1.tileSize), (float)(Game1.viewport.Y / Game1.tileSize)))))
+ if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / 64), (float)(Game1.viewport.Y / 64)))))
{
for (int index = 0; index < Game1.rainDrops.Length; ++index)
Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White);
}
Game1.spriteBatch.End();
- //base.Draw(gameTime);
Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
if (Game1.eventUp && Game1.currentLocation.currentEvent != null)
{
@@ -1770,12 +1206,12 @@ namespace StardewModdingAPI.Framework
if (actor.isEmoting)
{
Vector2 localPosition = actor.getLocalPosition(Game1.viewport);
- localPosition.Y -= (float)(Game1.tileSize * 2 + Game1.pixelZoom * 3);
- if (actor.age == 2)
- localPosition.Y += (float)(Game1.tileSize / 2);
- else if (actor.gender == 1)
- localPosition.Y += (float)(Game1.tileSize / 6);
- Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * (Game1.tileSize / 4) % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * (Game1.tileSize / 4) / Game1.emoteSpriteSheet.Width * (Game1.tileSize / 4), Game1.tileSize / 4, Game1.tileSize / 4)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f);
+ localPosition.Y -= 140f;
+ if (actor.Age == 2)
+ localPosition.Y += 32f;
+ else if (actor.Gender == 1)
+ localPosition.Y += 10f;
+ Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f);
}
}
}
@@ -1784,31 +1220,31 @@ namespace StardewModdingAPI.Framework
{
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null);
Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f);
- if (Game1.isRaining && Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))
+ if (Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert))
Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f);
Game1.spriteBatch.End();
}
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
if (Game1.drawGrid)
{
- int x1 = -Game1.viewport.X % Game1.tileSize;
- float num1 = (float)(-Game1.viewport.Y % Game1.tileSize);
+ int x1 = -Game1.viewport.X % 64;
+ float num1 = (float)(-Game1.viewport.Y % 64);
int x2 = x1;
while (x2 < Game1.graphics.GraphicsDevice.Viewport.Width)
{
Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x2, (int)num1, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f);
- x2 += Game1.tileSize;
+ x2 += 64;
}
float num2 = num1;
while ((double)num2 < (double)Game1.graphics.GraphicsDevice.Viewport.Height)
{
Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x1, (int)num2, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f);
- num2 += (float)Game1.tileSize;
+ num2 += 64f;
}
}
if (Game1.currentBillboard != 0)
this.drawBillboard();
- if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && (int)Game1.gameMode == 3) && (!Game1.freezeControls && !Game1.panMode))
+ if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && !Game1.HostPaused))
{
this.Events.Graphics_OnPreRenderHudEvent.Raise();
this.drawHUD();
@@ -1826,120 +1262,75 @@ namespace StardewModdingAPI.Framework
Game1.farmEvent.draw(Game1.spriteBatch);
if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)))
this.drawDialogueBox();
- Viewport viewport;
if (Game1.progressBar)
{
SpriteBatch spriteBatch1 = Game1.spriteBatch;
Texture2D fadeToBlackRect = Game1.fadeToBlackRect;
- int x1 = (Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2;
- rectangle = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea;
- int y1 = rectangle.Bottom - Game1.tileSize * 2;
+ int x1 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2;
+ rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea();
+ int y1 = rectangle.Bottom - 128;
int dialogueWidth = Game1.dialogueWidth;
- int height1 = Game1.tileSize / 2;
+ int height1 = 32;
Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, height1);
Color lightGray = Color.LightGray;
spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray);
SpriteBatch spriteBatch2 = Game1.spriteBatch;
Texture2D staminaRect = Game1.staminaRect;
- viewport = Game1.graphics.GraphicsDevice.Viewport;
- int x2 = (viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2;
- viewport = Game1.graphics.GraphicsDevice.Viewport;
- rectangle = viewport.TitleSafeArea;
- int y2 = rectangle.Bottom - Game1.tileSize * 2;
+ int x2 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2;
+ rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea();
+ int y2 = rectangle.Bottom - 128;
int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth);
- int height2 = Game1.tileSize / 2;
+ int height2 = 32;
Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, height2);
Color dimGray = Color.DimGray;
spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray);
}
if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null)
Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch);
- if (Game1.isRaining && Game1.currentLocation != null && (Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D staminaRect = Game1.staminaRect;
- viewport = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds;
- Color color = Color.Blue * 0.2f;
- spriteBatch.Draw(staminaRect, bounds, color);
- }
+ if (Game1.isRaining && Game1.currentLocation != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert)))
+ Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Blue * 0.2f);
if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause))
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D fadeToBlackRect = Game1.fadeToBlackRect;
- viewport = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds;
- Color color = Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha);
- spriteBatch.Draw(fadeToBlackRect, bounds, color);
- }
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha));
else if ((double)Game1.flashAlpha > 0.0)
{
if (Game1.options.screenFlash)
- {
- SpriteBatch spriteBatch = Game1.spriteBatch;
- Texture2D fadeToBlackRect = Game1.fadeToBlackRect;
- viewport = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds;
- Color color = Color.White * Math.Min(1f, Game1.flashAlpha);
- spriteBatch.Draw(fadeToBlackRect, bounds, color);
- }
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.White * Math.Min(1f, Game1.flashAlpha));
Game1.flashAlpha -= 0.1f;
}
if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp)
this.drawDialogueBox();
foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites)
- overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0);
+ overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f);
if (Game1.debugMode)
{
- SpriteBatch spriteBatch = Game1.spriteBatch;
- SpriteFont smallFont = Game1.smallFont;
- object[] objArray = new object[10];
- int index1 = 0;
- string str1;
- if (!Game1.panMode)
- str1 = "player: " + (object)(Game1.player.getStandingX() / Game1.tileSize) + ", " + (object)(Game1.player.getStandingY() / Game1.tileSize);
+ StringBuilder debugStringBuilder = Game1._debugStringBuilder;
+ debugStringBuilder.Clear();
+ if (Game1.panMode)
+ {
+ debugStringBuilder.Append((Game1.getOldMouseX() + Game1.viewport.X) / 64);
+ debugStringBuilder.Append(",");
+ debugStringBuilder.Append((Game1.getOldMouseY() + Game1.viewport.Y) / 64);
+ }
else
- str1 = ((Game1.getOldMouseX() + Game1.viewport.X) / Game1.tileSize).ToString() + "," + (object)((Game1.getOldMouseY() + Game1.viewport.Y) / Game1.tileSize);
- objArray[index1] = (object)str1;
- int index2 = 1;
- string str2 = " mouseTransparency: ";
- objArray[index2] = (object)str2;
- int index3 = 2;
- float cursorTransparency = Game1.mouseCursorTransparency;
- objArray[index3] = (object)cursorTransparency;
- int index4 = 3;
- string str3 = " mousePosition: ";
- objArray[index4] = (object)str3;
- int index5 = 4;
- int mouseX = Game1.getMouseX();
- objArray[index5] = (object)mouseX;
- int index6 = 5;
- string str4 = ",";
- objArray[index6] = (object)str4;
- int index7 = 6;
- int mouseY = Game1.getMouseY();
- objArray[index7] = (object)mouseY;
- int index8 = 7;
- string newLine = Environment.NewLine;
- objArray[index8] = (object)newLine;
- int index9 = 8;
- string str5 = "debugOutput: ";
- objArray[index9] = (object)str5;
- int index10 = 9;
- string debugOutput = Game1.debugOutput;
- objArray[index10] = (object)debugOutput;
- string text = string.Concat(objArray);
- Vector2 position = new Vector2((float)this.GraphicsDevice.Viewport.TitleSafeArea.X, (float)this.GraphicsDevice.Viewport.TitleSafeArea.Y);
- Color red = Color.Red;
- double num1 = 0.0;
- Vector2 zero = Vector2.Zero;
- double num2 = 1.0;
- int num3 = 0;
- double num4 = 0.99999988079071;
- spriteBatch.DrawString(smallFont, text, position, red, (float)num1, zero, (float)num2, (SpriteEffects)num3, (float)num4);
+ {
+ debugStringBuilder.Append("player: ");
+ debugStringBuilder.Append(Game1.player.getStandingX() / 64);
+ debugStringBuilder.Append(", ");
+ debugStringBuilder.Append(Game1.player.getStandingY() / 64);
+ }
+ debugStringBuilder.Append(" mouseTransparency: ");
+ debugStringBuilder.Append(Game1.mouseCursorTransparency);
+ debugStringBuilder.Append(" mousePosition: ");
+ debugStringBuilder.Append(Game1.getMouseX());
+ debugStringBuilder.Append(",");
+ debugStringBuilder.Append(Game1.getMouseY());
+ debugStringBuilder.Append(Environment.NewLine);
+ debugStringBuilder.Append("debugOutput: ");
+ debugStringBuilder.Append(Game1.debugOutput);
+ Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
}
if (Game1.showKeyHelp)
- Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2((float)Game1.tileSize, (float)(Game1.viewport.Height - Game1.tileSize - (Game1.dialogueUp ? Game1.tileSize * 3 + (Game1.isQuestion ? Game1.questionChoices.Count * Game1.tileSize : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
+ Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
if (Game1.activeClickableMenu != null)
{
try
@@ -1956,76 +1347,30 @@ namespace StardewModdingAPI.Framework
}
else if (Game1.farmEvent != null)
Game1.farmEvent.drawAboveEverything(Game1.spriteBatch);
-
- this.RaisePostRender();
- Game1.spriteBatch.End();
- if (Game1.overlayMenu != null)
+ if (Game1.HostPaused)
{
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
- Game1.spriteBatch.End();
+ string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378");
+ SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1);
}
+ this.RaisePostRender();
+ Game1.spriteBatch.End();
+ this.drawOverlays(Game1.spriteBatch);
this.renderScreenBuffer();
+ //base.Draw(gameTime);
}
}
}
}
-#endif
/****
** Methods
****/
- /// <summary>Perform any cleanup needed when the player unloads a save and returns to the title screen.</summary>
- private void CleanupAfterReturnToTitle()
+ /// <summary>Perform any cleanup needed when a save is unloaded.</summary>
+ private void MarkWorldNotReady()
{
Context.IsWorldReady = false;
- this.AfterLoadTimer = 5;
- this.PreviousSaveID = 0;
- }
-
-
-
- /// <summary>Get the player inventory changes between two states.</summary>
- /// <param name="current">The player's current inventory.</param>
- /// <param name="previous">The player's previous inventory.</param>
- private IEnumerable<ItemStackChange> GetInventoryChanges(IEnumerable<Item> current, IDictionary<Item, int> previous)
- {
- current = current.Where(n => n != null).ToArray();
- foreach (Item item in current)
- {
- // stack size changed
- if (previous != null && previous.ContainsKey(item))
- {
- if (previous[item] != item.Stack)
- yield return new ItemStackChange { Item = item, StackChange = item.Stack - previous[item], ChangeType = ChangeType.StackChange };
- }
-
- // new item
- else
- yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added };
- }
-
- // removed items
- if (previous != null)
- {
- foreach (var entry in previous)
- {
- if (current.Any(i => i == entry.Key))
- continue;
-
- yield return new ItemStackChange { Item = entry.Key, StackChange = -entry.Key.Stack, ChangeType = ChangeType.Removed };
- }
- }
- }
-
- /// <summary>Get a hash value for an enumeration.</summary>
- /// <param name="enumerable">The enumeration of items to hash.</param>
- private int GetHash(IEnumerable enumerable)
- {
- int hash = 0;
- foreach (object v in enumerable)
- hash ^= v.GetHashCode();
- return hash;
+ this.AfterLoadTimer.Reset();
+ this.RaisedAfterLoadEvent = false;
}
/// <summary>Raise the <see cref="GraphicsEvents.OnPostRenderEvent"/> if there are any listeners.</summary>
diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs
new file mode 100644
index 00000000..687b1922
--- /dev/null
+++ b/src/SMAPI/Framework/SMultiplayer.cs
@@ -0,0 +1,47 @@
+using StardewModdingAPI.Framework.Events;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary>
+ internal class SMultiplayer : Multiplayer
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>Manages SMAPI events.</summary>
+ private readonly EventManager EventManager;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="eventManager">Manages SMAPI events.</param>
+ public SMultiplayer(IMonitor monitor, EventManager eventManager)
+ {
+ this.Monitor = monitor;
+ this.EventManager = eventManager;
+ }
+
+ /// <summary>Handle sync messages from other players and perform other initial sync logic.</summary>
+ public override void UpdateEarly()
+ {
+ this.EventManager.Multiplayer_BeforeMainSync.Raise();
+ base.UpdateEarly();
+ this.EventManager.Multiplayer_AfterMainSync.Raise();
+ }
+
+ /// <summary>Broadcast sync messages to other players and perform other final sync logic.</summary>
+ public override void UpdateLate(bool forceSync = false)
+ {
+ this.EventManager.Multiplayer_BeforeMainBroadcast.Raise();
+ base.UpdateLate(forceSync);
+ this.EventManager.Multiplayer_AfterMainBroadcast.Raise();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs b/src/SMAPI/Framework/Serialisation/ColorConverter.cs
index f1b2f04f..c27065bf 100644
--- a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/ColorConverter.cs
+++ b/src/SMAPI/Framework/Serialisation/ColorConverter.cs
@@ -1,9 +1,10 @@
using System;
using Microsoft.Xna.Framework;
using Newtonsoft.Json.Linq;
-using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
-namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters
+namespace StardewModdingAPI.Framework.Serialisation
{
/// <summary>Handles deserialisation of <see cref="Color"/> for crossplatform compatibility.</summary>
/// <remarks>
diff --git a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs b/src/SMAPI/Framework/Serialisation/PointConverter.cs
index 434b7ea5..fbc857d2 100644
--- a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/PointConverter.cs
+++ b/src/SMAPI/Framework/Serialisation/PointConverter.cs
@@ -1,9 +1,10 @@
using System;
using Microsoft.Xna.Framework;
using Newtonsoft.Json.Linq;
-using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
-namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters
+namespace StardewModdingAPI.Framework.Serialisation
{
/// <summary>Handles deserialisation of <see cref="PointConverter"/> for crossplatform compatibility.</summary>
/// <remarks>
diff --git a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs b/src/SMAPI/Framework/Serialisation/RectangleConverter.cs
index 62bc8637..4f55cc32 100644
--- a/src/SMAPI/Framework/Serialisation/CrossplatformConverters/RectangleConverter.cs
+++ b/src/SMAPI/Framework/Serialisation/RectangleConverter.cs
@@ -2,9 +2,10 @@ using System;
using System.Text.RegularExpressions;
using Microsoft.Xna.Framework;
using Newtonsoft.Json.Linq;
-using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
-namespace StardewModdingAPI.Framework.Serialisation.CrossplatformConverters
+namespace StardewModdingAPI.Framework.Serialisation
{
/// <summary>Handles deserialisation of <see cref="Rectangle"/> for crossplatform compatibility.</summary>
/// <remarks>
diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs
deleted file mode 100644
index 7ee7e29b..00000000
--- a/src/SMAPI/Framework/Serialisation/SmapiConverters/SemanticVersionConverter.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using Newtonsoft.Json.Linq;
-using StardewModdingAPI.Framework.Exceptions;
-
-namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
-{
- /// <summary>Handles deserialisation of <see cref="SemanticVersion"/>.</summary>
- internal class SemanticVersionConverter : SimpleReadOnlyConverter<ISemanticVersion>
- {
- /*********
- ** Protected methods
- *********/
- /// <summary>Read a JSON object.</summary>
- /// <param name="obj">The JSON object to read.</param>
- /// <param name="path">The path to the current JSON node.</param>
- protected override ISemanticVersion ReadObject(JObject obj, string path)
- {
- int major = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MajorVersion));
- int minor = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MinorVersion));
- int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion));
- string build = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.Build));
- return new LegacyManifestVersion(major, minor, patch, build);
- }
-
- /// <summary>Read a JSON string.</summary>
- /// <param name="str">The JSON string value.</param>
- /// <param name="path">The path to the current JSON node.</param>
- protected override ISemanticVersion ReadString(string str, string path)
- {
- if (string.IsNullOrWhiteSpace(str))
- return null;
- if (!SemanticVersion.TryParse(str, out ISemanticVersion version))
- throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path}).");
- return version;
- }
- }
-}
diff --git a/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs
new file mode 100644
index 00000000..a96ffdb6
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace StardewModdingAPI.Framework.StateTracking.Comparers
+{
+ /// <summary>Compares instances using <see cref="IEqualityComparer{T}.Equals(T,T)"/>.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ internal class EquatableComparer<T> : IEqualityComparer<T> where T : IEquatable<T>
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Determines whether the specified objects are equal.</summary>
+ /// <returns>true if the specified objects are equal; otherwise, false.</returns>
+ /// <param name="x">The first object to compare.</param>
+ /// <param name="y">The second object to compare.</param>
+ public bool Equals(T x, T y)
+ {
+ if (x == null)
+ return y == null;
+ return x.Equals(y);
+ }
+
+ /// <summary>Get a hash code for the specified object.</summary>
+ /// <param name="obj">The value.</param>
+ public int GetHashCode(T obj)
+ {
+ return RuntimeHelpers.GetHashCode(obj);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs
new file mode 100644
index 00000000..cc1d6553
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace StardewModdingAPI.Framework.StateTracking.Comparers
+{
+ /// <summary>Compares values using their <see cref="object.Equals(object)"/> method. This should only be used when <see cref="EquatableComparer{T}"/> won't work, since this doesn't validate whether they're comparable.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ internal class GenericEqualsComparer<T> : IEqualityComparer<T>
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Determines whether the specified objects are equal.</summary>
+ /// <returns>true if the specified objects are equal; otherwise, false.</returns>
+ /// <param name="x">The first object to compare.</param>
+ /// <param name="y">The second object to compare.</param>
+ public bool Equals(T x, T y)
+ {
+ if (x == null)
+ return y == null;
+ return x.Equals(y);
+ }
+
+ /// <summary>Get a hash code for the specified object.</summary>
+ /// <param name="obj">The value.</param>
+ public int GetHashCode(T obj)
+ {
+ return RuntimeHelpers.GetHashCode(obj);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs
new file mode 100644
index 00000000..ef9adafb
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace StardewModdingAPI.Framework.StateTracking.Comparers
+{
+ /// <summary>A comparer which considers two references equal if they point to the same instance.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ internal class ObjectReferenceComparer<T> : IEqualityComparer<T>
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Determines whether the specified objects are equal.</summary>
+ /// <returns>true if the specified objects are equal; otherwise, false.</returns>
+ /// <param name="x">The first object to compare.</param>
+ /// <param name="y">The second object to compare.</param>
+ public bool Equals(T x, T y)
+ {
+ return object.ReferenceEquals(x, y);
+ }
+
+ /// <summary>Get a hash code for the specified object.</summary>
+ /// <param name="obj">The value.</param>
+ public int GetHashCode(T obj)
+ {
+ return RuntimeHelpers.GetHashCode(obj);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/BaseDisposableWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/BaseDisposableWatcher.cs
new file mode 100644
index 00000000..40ec6c57
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/BaseDisposableWatcher.cs
@@ -0,0 +1,36 @@
+using System;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>The base implementation for a disposable watcher.</summary>
+ internal abstract class BaseDisposableWatcher : IDisposable
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Whether the watcher has been disposed.</summary>
+ protected bool IsDisposed { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Stop watching the field and release all references.</summary>
+ public virtual void Dispose()
+ {
+ this.IsDisposed = true;
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Throw an exception if the watcher is disposed.</summary>
+ /// <exception cref="ObjectDisposedException">The watcher is disposed.</exception>
+ protected void AssertNotDisposed()
+ {
+ if (this.IsDisposed)
+ throw new ObjectDisposedException(this.GetType().Name);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs
new file mode 100644
index 00000000..d51fc2ac
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>A watcher which detects changes to a value using a specified <see cref="IEqualityComparer{T}"/> instance.</summary>
+ internal class ComparableWatcher<T> : IValueWatcher<T>
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Get the current value.</summary>
+ private readonly Func<T> GetValue;
+
+ /// <summary>The equality comparer.</summary>
+ private readonly IEqualityComparer<T> Comparer;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The field value at the last reset.</summary>
+ public T PreviousValue { get; private set; }
+
+ /// <summary>The latest value.</summary>
+ public T CurrentValue { get; private set; }
+
+ /// <summary>Whether the value changed since the last reset.</summary>
+ public bool IsChanged { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="getValue">Get the current value.</param>
+ /// <param name="comparer">The equality comparer which indicates whether two values are the same.</param>
+ public ComparableWatcher(Func<T> getValue, IEqualityComparer<T> comparer)
+ {
+ this.GetValue = getValue;
+ this.Comparer = comparer;
+ this.PreviousValue = getValue();
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ this.CurrentValue = this.GetValue();
+ this.IsChanged = !this.Comparer.Equals(this.PreviousValue, this.CurrentValue);
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.PreviousValue = this.CurrentValue;
+ this.IsChanged = false;
+ }
+
+ /// <summary>Release any references if needed when the field is no longer needed.</summary>
+ public void Dispose() { }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs
new file mode 100644
index 00000000..f92edb90
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs
@@ -0,0 +1,93 @@
+using System.Collections.Generic;
+using Netcode;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>A watcher which detects changes to a Netcode collection.</summary>
+ internal class NetCollectionWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue>
+ where TValue : INetObject<INetSerializable>
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The field being watched.</summary>
+ private readonly NetCollection<TValue> Field;
+
+ /// <summary>The pairs added since the last reset.</summary>
+ private readonly List<TValue> AddedImpl = new List<TValue>();
+
+ /// <summary>The pairs demoved since the last reset.</summary>
+ private readonly List<TValue> RemovedImpl = new List<TValue>();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the collection changed since the last reset.</summary>
+ public bool IsChanged => this.AddedImpl.Count > 0 || this.RemovedImpl.Count > 0;
+
+ /// <summary>The values added since the last reset.</summary>
+ public IEnumerable<TValue> Added => this.AddedImpl;
+
+ /// <summary>The values removed since the last reset.</summary>
+ public IEnumerable<TValue> Removed => this.RemovedImpl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="field">The field to watch.</param>
+ public NetCollectionWatcher(NetCollection<TValue> field)
+ {
+ this.Field = field;
+ field.OnValueAdded += this.OnValueAdded;
+ field.OnValueRemoved += this.OnValueRemoved;
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ this.AssertNotDisposed();
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.AssertNotDisposed();
+
+ this.AddedImpl.Clear();
+ this.RemovedImpl.Clear();
+ }
+
+ /// <summary>Stop watching the field and release all references.</summary>
+ public override void Dispose()
+ {
+ if (!this.IsDisposed)
+ {
+ this.Field.OnValueAdded -= this.OnValueAdded;
+ this.Field.OnValueRemoved -= this.OnValueRemoved;
+ }
+
+ base.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A callback invoked when an entry is added to the collection.</summary>
+ /// <param name="value">The added value.</param>
+ private void OnValueAdded(TValue value)
+ {
+ this.AddedImpl.Add(value);
+ }
+
+ /// <summary>A callback invoked when an entry is removed from the collection.</summary>
+ /// <param name="value">The added value.</param>
+ private void OnValueRemoved(TValue value)
+ {
+ this.RemovedImpl.Add(value);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs
new file mode 100644
index 00000000..7a2bf84e
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs
@@ -0,0 +1,103 @@
+using System.Collections.Generic;
+using Netcode;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>A watcher which detects changes to a net dictionary field.</summary>
+ /// <typeparam name="TKey">The dictionary key type.</typeparam>
+ /// <typeparam name="TValue">The dictionary value type.</typeparam>
+ /// <typeparam name="TField">The net type equivalent to <typeparamref name="TValue"/>.</typeparam>
+ /// <typeparam name="TSerialDict">The serializable dictionary type that can store the keys and values.</typeparam>
+ /// <typeparam name="TSelf">The net field instance type.</typeparam>
+ internal class NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf> : BaseDisposableWatcher, IDictionaryWatcher<TKey, TValue>
+ where TField : class, INetObject<INetSerializable>, new()
+ where TSerialDict : IDictionary<TKey, TValue>, new()
+ where TSelf : NetDictionary<TKey, TValue, TField, TSerialDict, TSelf>
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The pairs added since the last reset.</summary>
+ private readonly IDictionary<TKey, TValue> PairsAdded = new Dictionary<TKey, TValue>();
+
+ /// <summary>The pairs demoved since the last reset.</summary>
+ private readonly IDictionary<TKey, TValue> PairsRemoved = new Dictionary<TKey, TValue>();
+
+ /// <summary>The field being watched.</summary>
+ private readonly NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> Field;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the collection changed since the last reset.</summary>
+ public bool IsChanged => this.PairsAdded.Count > 0 || this.PairsRemoved.Count > 0;
+
+ /// <summary>The values added since the last reset.</summary>
+ public IEnumerable<KeyValuePair<TKey, TValue>> Added => this.PairsAdded;
+
+ /// <summary>The values removed since the last reset.</summary>
+ public IEnumerable<KeyValuePair<TKey, TValue>> Removed => this.PairsRemoved;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="field">The field to watch.</param>
+ public NetDictionaryWatcher(NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> field)
+ {
+ this.Field = field;
+
+ field.OnValueAdded += this.OnValueAdded;
+ field.OnValueRemoved += this.OnValueRemoved;
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ this.AssertNotDisposed();
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.AssertNotDisposed();
+
+ this.PairsAdded.Clear();
+ this.PairsRemoved.Clear();
+ }
+
+ /// <summary>Stop watching the field and release all references.</summary>
+ public override void Dispose()
+ {
+ if (!this.IsDisposed)
+ {
+ this.Field.OnValueAdded -= this.OnValueAdded;
+ this.Field.OnValueRemoved -= this.OnValueRemoved;
+ }
+ base.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A callback invoked when an entry is added to the dictionary.</summary>
+ /// <param name="key">The entry key.</param>
+ /// <param name="value">The entry value.</param>
+ private void OnValueAdded(TKey key, TValue value)
+ {
+ this.PairsAdded[key] = value;
+ }
+
+ /// <summary>A callback invoked when an entry is removed from the dictionary.</summary>
+ /// <param name="key">The entry key.</param>
+ /// <param name="value">The entry value.</param>
+ private void OnValueRemoved(TKey key, TValue value)
+ {
+ if (!this.PairsRemoved.ContainsKey(key))
+ this.PairsRemoved[key] = value;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetValueWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetValueWatcher.cs
new file mode 100644
index 00000000..188ed9f3
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetValueWatcher.cs
@@ -0,0 +1,83 @@
+using Netcode;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>A watcher which detects changes to a net value field.</summary>
+ internal class NetValueWatcher<T, TSelf> : BaseDisposableWatcher, IValueWatcher<T> where TSelf : NetFieldBase<T, TSelf>
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The field being watched.</summary>
+ private readonly NetFieldBase<T, TSelf> Field;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the value changed since the last reset.</summary>
+ public bool IsChanged { get; private set; }
+
+ /// <summary>The field value at the last reset.</summary>
+ public T PreviousValue { get; private set; }
+
+ /// <summary>The latest value.</summary>
+ public T CurrentValue { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="field">The field to watch.</param>
+ public NetValueWatcher(NetFieldBase<T, TSelf> field)
+ {
+ this.Field = field;
+ this.PreviousValue = field.Value;
+ this.CurrentValue = field.Value;
+
+ field.fieldChangeVisibleEvent += this.OnValueChanged;
+ field.fieldChangeEvent += this.OnValueChanged;
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ this.AssertNotDisposed();
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.AssertNotDisposed();
+
+ this.PreviousValue = this.CurrentValue;
+ this.IsChanged = false;
+ }
+
+ /// <summary>Stop watching the field and release all references.</summary>
+ public override void Dispose()
+ {
+ if (!this.IsDisposed)
+ {
+ this.Field.fieldChangeEvent -= this.OnValueChanged;
+ this.Field.fieldChangeVisibleEvent -= this.OnValueChanged;
+ }
+ base.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A callback invoked when the field's value changes.</summary>
+ /// <param name="field">The field being watched.</param>
+ /// <param name="oldValue">The old field value.</param>
+ /// <param name="newValue">The new field value.</param>
+ private void OnValueChanged(TSelf field, T oldValue, T newValue)
+ {
+ this.CurrentValue = newValue;
+ this.IsChanged = true;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
new file mode 100644
index 00000000..34a97097
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
@@ -0,0 +1,86 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Linq;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>A watcher which detects changes to an observable collection.</summary>
+ internal class ObservableCollectionWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue>
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The field being watched.</summary>
+ private readonly ObservableCollection<TValue> Field;
+
+ /// <summary>The pairs added since the last reset.</summary>
+ private readonly List<TValue> AddedImpl = new List<TValue>();
+
+ /// <summary>The pairs demoved since the last reset.</summary>
+ private readonly List<TValue> RemovedImpl = new List<TValue>();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the collection changed since the last reset.</summary>
+ public bool IsChanged => this.AddedImpl.Count > 0 || this.RemovedImpl.Count > 0;
+
+ /// <summary>The values added since the last reset.</summary>
+ public IEnumerable<TValue> Added => this.AddedImpl;
+
+ /// <summary>The values removed since the last reset.</summary>
+ public IEnumerable<TValue> Removed => this.RemovedImpl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="field">The field to watch.</param>
+ public ObservableCollectionWatcher(ObservableCollection<TValue> field)
+ {
+ this.Field = field;
+ field.CollectionChanged += this.OnCollectionChanged;
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ this.AssertNotDisposed();
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.AssertNotDisposed();
+
+ this.AddedImpl.Clear();
+ this.RemovedImpl.Clear();
+ }
+
+ /// <summary>Stop watching the field and release all references.</summary>
+ public override void Dispose()
+ {
+ if (!this.IsDisposed)
+ this.Field.CollectionChanged -= this.OnCollectionChanged;
+ base.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A callback invoked when an entry is added or removed from the collection.</summary>
+ /// <param name="sender">The event sender.</param>
+ /// <param name="e">The event arguments.</param>
+ private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.NewItems != null)
+ this.AddedImpl.AddRange(e.NewItems.Cast<TValue>());
+ if (e.OldItems != null)
+ this.RemovedImpl.AddRange(e.OldItems.Cast<TValue>());
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
new file mode 100644
index 00000000..d7a02668
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using Netcode;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>Provides convenience wrappers for creating watchers.</summary>
+ internal static class WatcherFactory
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a watcher which compares values using their <see cref="object.Equals(object)"/> method. This method should only be used when <see cref="ForEquatable{T}"/> won't work, since this doesn't validate whether they're comparable.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="getValue">Get the current value.</param>
+ public static ComparableWatcher<T> ForGenericEquality<T>(Func<T> getValue) where T : struct
+ {
+ return new ComparableWatcher<T>(getValue, new GenericEqualsComparer<T>());
+ }
+
+ /// <summary>Get a watcher for an <see cref="IEquatable{T}"/> value.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="getValue">Get the current value.</param>
+ public static ComparableWatcher<T> ForEquatable<T>(Func<T> getValue) where T : IEquatable<T>
+ {
+ return new ComparableWatcher<T>(getValue, new EquatableComparer<T>());
+ }
+
+ /// <summary>Get a watcher which detects when an object reference changes.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="getValue">Get the current value.</param>
+ public static ComparableWatcher<T> ForReference<T>(Func<T> getValue)
+ {
+ return new ComparableWatcher<T>(getValue, new ObjectReferenceComparer<T>());
+ }
+
+ /// <summary>Get a watcher for an observable collection.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="collection">The observable collection.</param>
+ public static ObservableCollectionWatcher<T> ForObservableCollection<T>(ObservableCollection<T> collection)
+ {
+ return new ObservableCollectionWatcher<T>(collection);
+ }
+
+ /// <summary>Get a watcher for a net collection.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <typeparam name="TSelf">The net field instance type.</typeparam>
+ /// <param name="field">The net collection.</param>
+ public static NetValueWatcher<T, TSelf> ForNetValue<T, TSelf>(NetFieldBase<T, TSelf> field) where TSelf : NetFieldBase<T, TSelf>
+ {
+ return new NetValueWatcher<T, TSelf>(field);
+ }
+
+ /// <summary>Get a watcher for a net collection.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="collection">The net collection.</param>
+ public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : INetObject<INetSerializable>
+ {
+ return new NetCollectionWatcher<T>(collection);
+ }
+
+ /// <summary>Get a watcher for a net dictionary.</summary>
+ /// <typeparam name="TKey">The dictionary key type.</typeparam>
+ /// <typeparam name="TValue">The dictionary value type.</typeparam>
+ /// <typeparam name="TField">The net type equivalent to <typeparamref name="TValue"/>.</typeparam>
+ /// <typeparam name="TSerialDict">The serializable dictionary type that can store the keys and values.</typeparam>
+ /// <typeparam name="TSelf">The net field instance type.</typeparam>
+ /// <param name="field">The net field.</param>
+ public static NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf> ForNetDictionary<TKey, TValue, TField, TSerialDict, TSelf>(NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> field)
+ where TField : class, INetObject<INetSerializable>, new()
+ where TSerialDict : IDictionary<TKey, TValue>, new()
+ where TSelf : NetDictionary<TKey, TValue, TField, TSerialDict, TSelf>
+ {
+ return new NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf>(field);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/ICollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/ICollectionWatcher.cs
new file mode 100644
index 00000000..7a7759e3
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/ICollectionWatcher.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>A watcher which tracks changes to a collection.</summary>
+ internal interface ICollectionWatcher<out TValue> : IWatcher
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The values added since the last reset.</summary>
+ IEnumerable<TValue> Added { get; }
+
+ /// <summary>The values removed since the last reset.</summary>
+ IEnumerable<TValue> Removed { get; }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/IDictionaryWatcher.cs b/src/SMAPI/Framework/StateTracking/IDictionaryWatcher.cs
new file mode 100644
index 00000000..691ed377
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/IDictionaryWatcher.cs
@@ -0,0 +1,7 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>A watcher which tracks changes to a dictionary.</summary>
+ internal interface IDictionaryWatcher<TKey, TValue> : ICollectionWatcher<KeyValuePair<TKey, TValue>> { }
+}
diff --git a/src/SMAPI/Framework/StateTracking/IValueWatcher.cs b/src/SMAPI/Framework/StateTracking/IValueWatcher.cs
new file mode 100644
index 00000000..4afca972
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/IValueWatcher.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>A watcher which tracks changes to a value.</summary>
+ internal interface IValueWatcher<out T> : IWatcher
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The field value at the last reset.</summary>
+ T PreviousValue { get; }
+
+ /// <summary>The latest value.</summary>
+ T CurrentValue { get; }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/IWatcher.cs b/src/SMAPI/Framework/StateTracking/IWatcher.cs
new file mode 100644
index 00000000..8c7fa51c
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/IWatcher.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>A watcher which detects changes to something.</summary>
+ internal interface IWatcher : IDisposable
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the value changed since the last reset.</summary>
+ bool IsChanged { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Update the current value if needed.</summary>
+ void Update();
+
+ /// <summary>Set the current value as the baseline.</summary>
+ void Reset();
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
new file mode 100644
index 00000000..708c0716
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
@@ -0,0 +1,103 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Microsoft.Xna.Framework;
+using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
+using StardewValley;
+using StardewValley.Buildings;
+using StardewValley.Locations;
+using StardewValley.TerrainFeatures;
+using Object = StardewValley.Object;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>Tracks changes to a location's data.</summary>
+ internal class LocationTracker : IWatcher
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying watchers.</summary>
+ private readonly List<IWatcher> Watchers = new List<IWatcher>();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the value changed since the last reset.</summary>
+ public bool IsChanged => this.Watchers.Any(p => p.IsChanged);
+
+ /// <summary>The tracked location.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>Tracks added or removed buildings.</summary>
+ public ICollectionWatcher<Building> BuildingsWatcher { get; }
+
+ /// <summary>Tracks added or removed debris.</summary>
+ public ICollectionWatcher<Debris> DebrisWatcher { get; }
+
+ /// <summary>Tracks added or removed large terrain features.</summary>
+ public ICollectionWatcher<LargeTerrainFeature> LargeTerrainFeaturesWatcher { get; }
+
+ /// <summary>Tracks added or removed NPCs.</summary>
+ public ICollectionWatcher<NPC> NpcsWatcher { get; }
+
+ /// <summary>Tracks added or removed objects.</summary>
+ public IDictionaryWatcher<Vector2, Object> ObjectsWatcher { get; }
+
+ /// <summary>Tracks added or removed terrain features.</summary>
+ public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="location">The location to track.</param>
+ public LocationTracker(GameLocation location)
+ {
+ this.Location = location;
+
+ // init watchers
+ this.BuildingsWatcher = location is BuildableGameLocation buildableLocation
+ ? WatcherFactory.ForNetCollection(buildableLocation.buildings)
+ : (ICollectionWatcher<Building>)WatcherFactory.ForObservableCollection(new ObservableCollection<Building>());
+ this.DebrisWatcher = WatcherFactory.ForNetCollection(location.debris);
+ this.LargeTerrainFeaturesWatcher = WatcherFactory.ForNetCollection(location.largeTerrainFeatures);
+ this.NpcsWatcher = WatcherFactory.ForNetCollection(location.characters);
+ this.ObjectsWatcher = WatcherFactory.ForNetDictionary(location.netObjects);
+ this.TerrainFeaturesWatcher = WatcherFactory.ForNetDictionary(location.terrainFeatures);
+
+ this.Watchers.AddRange(new IWatcher[]
+ {
+ this.BuildingsWatcher,
+ this.DebrisWatcher,
+ this.LargeTerrainFeaturesWatcher,
+ this.NpcsWatcher,
+ this.ObjectsWatcher,
+ this.TerrainFeaturesWatcher
+ });
+ }
+
+ /// <summary>Stop watching the player fields and release all references.</summary>
+ public void Dispose()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Dispose();
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Update();
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Reset();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
new file mode 100644
index 00000000..3814e534
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
+using StardewValley;
+using StardewValley.Locations;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>Tracks changes to a player's data.</summary>
+ internal class PlayerTracker : IDisposable
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The player's inventory as of the last reset.</summary>
+ private IDictionary<Item, int> PreviousInventory;
+
+ /// <summary>The player's inventory change as of the last update.</summary>
+ private IDictionary<Item, int> CurrentInventory;
+
+ /// <summary>The player's last valid location.</summary>
+ private GameLocation LastValidLocation;
+
+ /// <summary>The underlying watchers.</summary>
+ private readonly List<IWatcher> Watchers = new List<IWatcher>();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The player being tracked.</summary>
+ public Farmer Player { get; }
+
+ /// <summary>The player's current location.</summary>
+ public IValueWatcher<GameLocation> LocationWatcher { get; }
+
+ /// <summary>The player's current mine level.</summary>
+ public IValueWatcher<int> MineLevelWatcher { get; }
+
+ /// <summary>Tracks changes to the player's skill levels.</summary>
+ public IDictionary<EventArgsLevelUp.LevelType, IValueWatcher<int>> SkillWatchers { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="player">The player to track.</param>
+ public PlayerTracker(Farmer player)
+ {
+ // init player data
+ this.Player = player;
+ this.PreviousInventory = this.GetInventory();
+
+ // init trackers
+ this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation);
+ this.MineLevelWatcher = WatcherFactory.ForEquatable(() => this.LastValidLocation is MineShaft mine ? mine.mineLevel : 0);
+ this.SkillWatchers = new Dictionary<EventArgsLevelUp.LevelType, IValueWatcher<int>>
+ {
+ [EventArgsLevelUp.LevelType.Combat] = WatcherFactory.ForNetValue(player.combatLevel),
+ [EventArgsLevelUp.LevelType.Farming] = WatcherFactory.ForNetValue(player.farmingLevel),
+ [EventArgsLevelUp.LevelType.Fishing] = WatcherFactory.ForNetValue(player.fishingLevel),
+ [EventArgsLevelUp.LevelType.Foraging] = WatcherFactory.ForNetValue(player.foragingLevel),
+ [EventArgsLevelUp.LevelType.Luck] = WatcherFactory.ForNetValue(player.luckLevel),
+ [EventArgsLevelUp.LevelType.Mining] = WatcherFactory.ForNetValue(player.miningLevel)
+ };
+
+ // track watchers for convenience
+ this.Watchers.AddRange(new IWatcher[]
+ {
+ this.LocationWatcher,
+ this.MineLevelWatcher
+ });
+ this.Watchers.AddRange(this.SkillWatchers.Values);
+ }
+
+ /// <summary>Update the current values if needed.</summary>
+ public void Update()
+ {
+ // update valid location
+ this.LastValidLocation = this.GetCurrentLocation();
+
+ // update watchers
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Update();
+
+ // update inventory
+ this.CurrentInventory = this.GetInventory();
+ }
+
+ /// <summary>Reset all trackers so their current values are the baseline.</summary>
+ public void Reset()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Reset();
+
+ this.PreviousInventory = this.CurrentInventory;
+ }
+
+ /// <summary>Get the player's current location, ignoring temporary null values.</summary>
+ /// <remarks>The game will set <see cref="Character.currentLocation"/> to null in some cases, e.g. when they're a secondary player in multiplayer and transition to a location that hasn't been synced yet. While that's happening, this returns the player's last valid location instead.</remarks>
+ public GameLocation GetCurrentLocation()
+ {
+ return this.Player.currentLocation ?? this.LastValidLocation;
+ }
+
+ /// <summary>Get the player inventory changes between two states.</summary>
+ public IEnumerable<ItemStackChange> GetInventoryChanges()
+ {
+ IDictionary<Item, int> previous = this.PreviousInventory;
+ IDictionary<Item, int> current = this.GetInventory();
+ foreach (Item item in previous.Keys.Union(current.Keys))
+ {
+ if (!previous.TryGetValue(item, out int prevStack))
+ yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added };
+ else if (!current.TryGetValue(item, out int newStack))
+ yield return new ItemStackChange { Item = item, StackChange = -item.Stack, ChangeType = ChangeType.Removed };
+ else if (prevStack != newStack)
+ yield return new ItemStackChange { Item = item, StackChange = newStack - prevStack, ChangeType = ChangeType.StackChange };
+ }
+ }
+
+ /// <summary>Get the player skill levels which changed.</summary>
+ public IEnumerable<KeyValuePair<EventArgsLevelUp.LevelType, IValueWatcher<int>>> GetChangedSkills()
+ {
+ return this.SkillWatchers.Where(p => p.Value.IsChanged);
+ }
+
+ /// <summary>Get the player's new location if it changed.</summary>
+ /// <param name="location">The player's current location.</param>
+ /// <returns>Returns whether it changed.</returns>
+ public bool TryGetNewLocation(out GameLocation location)
+ {
+ location = this.LocationWatcher.CurrentValue;
+ return this.LocationWatcher.IsChanged;
+ }
+
+ /// <summary>Get the player's new mine level if it changed.</summary>
+ /// <param name="mineLevel">The player's current mine level.</param>
+ /// <returns>Returns whether it changed.</returns>
+ public bool TryGetNewMineLevel(out int mineLevel)
+ {
+ mineLevel = this.MineLevelWatcher.CurrentValue;
+ return this.MineLevelWatcher.IsChanged;
+ }
+
+ /// <summary>Stop watching the player fields and release all references.</summary>
+ public void Dispose()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the player's current inventory.</summary>
+ private IDictionary<Item, int> GetInventory()
+ {
+ return this.Player.Items
+ .Where(n => n != null)
+ .Distinct()
+ .ToDictionary(n => n, n => n.Stack);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs
new file mode 100644
index 00000000..d9090c08
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs
@@ -0,0 +1,221 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
+using StardewValley;
+using StardewValley.Buildings;
+using StardewValley.Locations;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>Detects changes to the game's locations.</summary>
+ internal class WorldLocationsTracker : IWatcher
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Tracks changes to the location list.</summary>
+ private readonly ICollectionWatcher<GameLocation> LocationListWatcher;
+
+ /// <summary>A lookup of the tracked locations.</summary>
+ private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>();
+
+ /// <summary>A lookup of registered buildings and their indoor location.</summary>
+ private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether locations were added or removed since the last reset.</summary>
+ public bool IsLocationListChanged => this.Added.Any() || this.Removed.Any();
+
+ /// <summary>Whether any tracked location data changed since the last reset.</summary>
+ public bool IsChanged => this.IsLocationListChanged || this.Locations.Any(p => p.IsChanged);
+
+ /// <summary>The tracked locations.</summary>
+ public IEnumerable<LocationTracker> Locations => this.LocationDict.Values;
+
+ /// <summary>The locations removed since the last update.</summary>
+ public ICollection<GameLocation> Added { get; } = new HashSet<GameLocation>();
+
+ /// <summary>The locations added since the last update.</summary>
+ public ICollection<GameLocation> Removed { get; } = new HashSet<GameLocation>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="locations">The game's list of locations.</param>
+ public WorldLocationsTracker(ObservableCollection<GameLocation> locations)
+ {
+ this.LocationListWatcher = WatcherFactory.ForObservableCollection(locations);
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ // detect location changes
+ if (this.LocationListWatcher.IsChanged)
+ {
+ this.Remove(this.LocationListWatcher.Removed);
+ this.Add(this.LocationListWatcher.Added);
+ }
+
+ // detect building changes
+ foreach (LocationTracker watcher in this.Locations.ToArray())
+ {
+ if (watcher.BuildingsWatcher.IsChanged)
+ {
+ this.Remove(watcher.BuildingsWatcher.Removed);
+ this.Add(watcher.BuildingsWatcher.Added);
+ }
+ }
+
+ // detect building interior changed (e.g. construction completed)
+ foreach (KeyValuePair<Building, GameLocation> pair in this.BuildingIndoors.Where(p => !object.Equals(p.Key.indoors.Value, p.Value)))
+ {
+ GameLocation oldIndoors = pair.Value;
+ GameLocation newIndoors = pair.Key.indoors.Value;
+
+ if (oldIndoors != null)
+ this.Added.Add(oldIndoors);
+ if (newIndoors != null)
+ this.Removed.Add(newIndoors);
+ }
+
+ // update watchers
+ foreach (IWatcher watcher in this.Locations)
+ watcher.Update();
+ }
+
+ /// <summary>Set the current location list as the baseline.</summary>
+ public void ResetLocationList()
+ {
+ this.Removed.Clear();
+ this.Added.Clear();
+ this.LocationListWatcher.Reset();
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.ResetLocationList();
+ foreach (IWatcher watcher in this.Locations)
+ watcher.Reset();
+ }
+
+ /// <summary>Stop watching the player fields and release all references.</summary>
+ public void Dispose()
+ {
+ this.LocationListWatcher.Dispose();
+ foreach (IWatcher watcher in this.Locations)
+ watcher.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /****
+ ** Enumerable wrappers
+ ****/
+ /// <summary>Add the given buildings.</summary>
+ /// <param name="buildings">The buildings to add.</param>
+ public void Add(IEnumerable<Building> buildings)
+ {
+ foreach (Building building in buildings)
+ this.Add(building);
+ }
+
+ /// <summary>Add the given locations.</summary>
+ /// <param name="locations">The locations to add.</param>
+ public void Add(IEnumerable<GameLocation> locations)
+ {
+ foreach (GameLocation location in locations)
+ this.Add(location);
+ }
+
+ /// <summary>Remove the given buildings.</summary>
+ /// <param name="buildings">The buildings to remove.</param>
+ public void Remove(IEnumerable<Building> buildings)
+ {
+ foreach (Building building in buildings)
+ this.Remove(building);
+ }
+
+ /// <summary>Remove the given locations.</summary>
+ /// <param name="locations">The locations to remove.</param>
+ public void Remove(IEnumerable<GameLocation> locations)
+ {
+ foreach (GameLocation location in locations)
+ this.Remove(location);
+ }
+
+ /****
+ ** Main add/remove logic
+ ****/
+ /// <summary>Add the given building.</summary>
+ /// <param name="building">The building to add.</param>
+ public void Add(Building building)
+ {
+ if (building == null)
+ return;
+
+ GameLocation indoors = building.indoors.Value;
+ this.BuildingIndoors[building] = indoors;
+ this.Add(indoors);
+ }
+
+ /// <summary>Add the given location.</summary>
+ /// <param name="location">The location to add.</param>
+ public void Add(GameLocation location)
+ {
+ if (location == null)
+ return;
+
+ // remove old location if needed
+ this.Remove(location);
+
+ // track change
+ this.Added.Add(location);
+
+ // add
+ this.LocationDict[location] = new LocationTracker(location);
+ if (location is BuildableGameLocation buildableLocation)
+ this.Add(buildableLocation.buildings);
+ }
+
+ /// <summary>Remove the given building.</summary>
+ /// <param name="building">The building to remove.</param>
+ public void Remove(Building building)
+ {
+ if (building == null)
+ return;
+
+ this.BuildingIndoors.Remove(building);
+ this.Remove(building.indoors.Value);
+ }
+
+ /// <summary>Remove the given location.</summary>
+ /// <param name="location">The location to remove.</param>
+ public void Remove(GameLocation location)
+ {
+ if (location == null)
+ return;
+
+ if (this.LocationDict.TryGetValue(location, out LocationTracker watcher))
+ {
+ // track change
+ this.Removed.Add(location);
+
+ // remove
+ this.LocationDict.Remove(location);
+ watcher.Dispose();
+ if (location is BuildableGameLocation buildableLocation)
+ this.Remove(buildableLocation.buildings);
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/WatcherCore.cs b/src/SMAPI/Framework/WatcherCore.cs
new file mode 100644
index 00000000..e06423b9
--- /dev/null
+++ b/src/SMAPI/Framework/WatcherCore.cs
@@ -0,0 +1,119 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using Microsoft.Xna.Framework;
+using StardewModdingAPI.Framework.Input;
+using StardewModdingAPI.Framework.StateTracking;
+using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
+using StardewValley;
+using StardewValley.Menus;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>Monitors the entire game state for changes, virally spreading watchers into any new entities that get created.</summary>
+ internal class WatcherCore
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying watchers for convenience. These are accessible individually as separate properties.</summary>
+ private readonly List<IWatcher> Watchers = new List<IWatcher>();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Tracks changes to the window size.</summary>
+ public readonly IValueWatcher<Point> WindowSizeWatcher;
+
+ /// <summary>Tracks changes to the current player.</summary>
+ public PlayerTracker CurrentPlayerTracker;
+
+ /// <summary>Tracks changes to the time of day (in 24-hour military format).</summary>
+ public readonly IValueWatcher<int> TimeWatcher;
+
+ /// <summary>Tracks changes to the save ID.</summary>
+ public readonly IValueWatcher<ulong> SaveIdWatcher;
+
+ /// <summary>Tracks changes to the game's locations.</summary>
+ public readonly WorldLocationsTracker LocationsWatcher;
+
+ /// <summary>Tracks changes to <see cref="Game1.activeClickableMenu"/>.</summary>
+ public readonly IValueWatcher<IClickableMenu> ActiveMenuWatcher;
+
+ /// <summary>Tracks changes to the cursor position.</summary>
+ public readonly IValueWatcher<ICursorPosition> CursorWatcher;
+
+ /// <summary>Tracks changes to the mouse wheel scroll.</summary>
+ public readonly IValueWatcher<int> MouseWheelScrollWatcher;
+
+ /// <summary>Tracks changes to the content locale.</summary>
+ public readonly IValueWatcher<LocalizedContentManager.LanguageCode> LocaleWatcher;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="inputState">Manages input visible to the game.</param>
+ public WatcherCore(SInputState inputState)
+ {
+ // init watchers
+ this.CursorWatcher = WatcherFactory.ForEquatable(() => inputState.CursorPosition);
+ this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => inputState.RealMouse.ScrollWheelValue);
+ this.SaveIdWatcher = WatcherFactory.ForEquatable(() => Game1.hasLoadedGame ? Game1.uniqueIDForThisGame : 0);
+ this.WindowSizeWatcher = WatcherFactory.ForEquatable(() => new Point(Game1.viewport.Width, Game1.viewport.Height));
+ this.TimeWatcher = WatcherFactory.ForEquatable(() => Game1.timeOfDay);
+ this.ActiveMenuWatcher = WatcherFactory.ForReference(() => Game1.activeClickableMenu);
+ this.LocationsWatcher = new WorldLocationsTracker((ObservableCollection<GameLocation>)Game1.locations);
+ this.LocaleWatcher = WatcherFactory.ForGenericEquality(() => LocalizedContentManager.CurrentLanguageCode);
+ this.Watchers.AddRange(new IWatcher[]
+ {
+ this.CursorWatcher,
+ this.MouseWheelScrollWatcher,
+ this.SaveIdWatcher,
+ this.WindowSizeWatcher,
+ this.TimeWatcher,
+ this.ActiveMenuWatcher,
+ this.LocationsWatcher,
+ this.LocaleWatcher
+ });
+ }
+
+ /// <summary>Update the watchers and adjust for added or removed entities.</summary>
+ public void Update()
+ {
+ // reset player
+ if (Context.IsWorldReady)
+ {
+ if (this.CurrentPlayerTracker == null || this.CurrentPlayerTracker.Player != Game1.player)
+ {
+ this.CurrentPlayerTracker?.Dispose();
+ this.CurrentPlayerTracker = new PlayerTracker(Game1.player);
+ }
+ }
+ else
+ {
+ if (this.CurrentPlayerTracker != null)
+ {
+ this.CurrentPlayerTracker.Dispose();
+ this.CurrentPlayerTracker = null;
+ }
+ }
+
+ // update values
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Update();
+ this.CurrentPlayerTracker?.Update();
+ this.LocationsWatcher.Update();
+ }
+
+ /// <summary>Reset the current values as the baseline.</summary>
+ public void Reset()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Reset();
+ this.CurrentPlayerTracker?.Reset();
+ this.LocationsWatcher.Reset();
+ }
+ }
+}
diff --git a/src/SMAPI/GamePlatform.cs b/src/SMAPI/GamePlatform.cs
new file mode 100644
index 00000000..3bd74462
--- /dev/null
+++ b/src/SMAPI/GamePlatform.cs
@@ -0,0 +1,17 @@
+using StardewModdingAPI.Internal;
+
+namespace StardewModdingAPI
+{
+ /// <summary>The game's platform version.</summary>
+ public enum GamePlatform
+ {
+ /// <summary>The Linux version of the game.</summary>
+ Linux = Platform.Linux,
+
+ /// <summary>The Mac version of the game.</summary>
+ Mac = Platform.Mac,
+
+ /// <summary>The Windows version of the game.</summary>
+ Windows = Platform.Windows
+ }
+}
diff --git a/src/SMAPI/IAssetDataForImage.cs b/src/SMAPI/IAssetDataForImage.cs
index 4584a20e..1109194f 100644
--- a/src/SMAPI/IAssetDataForImage.cs
+++ b/src/SMAPI/IAssetDataForImage.cs
@@ -1,10 +1,10 @@
-using System;
+using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace StardewModdingAPI
{
- /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary>
+ /// <summary>Encapsulates access and changes to image content being read from a data file.</summary>
public interface IAssetDataForImage : IAssetData<Texture2D>
{
/*********
diff --git a/src/SMAPI/ICursorPosition.cs b/src/SMAPI/ICursorPosition.cs
index ddb8eb49..21c57db0 100644
--- a/src/SMAPI/ICursorPosition.cs
+++ b/src/SMAPI/ICursorPosition.cs
@@ -1,10 +1,14 @@
+using System;
using Microsoft.Xna.Framework;
namespace StardewModdingAPI
{
/// <summary>Represents a cursor position in the different coordinate systems.</summary>
- public interface ICursorPosition
+ public interface ICursorPosition : IEquatable<ICursorPosition>
{
+ /// <summary>The pixel position relative to the top-left corner of the in-game map.</summary>
+ Vector2 AbsolutePixels { get; }
+
/// <summary>The pixel position relative to the top-left corner of the visible screen.</summary>
Vector2 ScreenPixels { get; }
diff --git a/src/SMAPI/IInputHelper.cs b/src/SMAPI/IInputHelper.cs
new file mode 100644
index 00000000..328f504b
--- /dev/null
+++ b/src/SMAPI/IInputHelper.cs
@@ -0,0 +1,21 @@
+namespace StardewModdingAPI
+{
+ /// <summary>Provides an API for checking and changing input state.</summary>
+ public interface IInputHelper : IModLinked
+ {
+ /// <summary>Get the current cursor position.</summary>
+ ICursorPosition GetCursorPosition();
+
+ /// <summary>Get whether a button is currently pressed.</summary>
+ /// <param name="button">The button.</param>
+ bool IsDown(SButton button);
+
+ /// <summary>Get whether a button is currently suppressed, so the game won't see it.</summary>
+ /// <param name="button">The button.</param>
+ bool IsSuppressed(SButton button);
+
+ /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary>
+ /// <param name="button">The button to suppress.</param>
+ void Suppress(SButton button);
+ }
+}
diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs
index e9554fdc..d7b8c986 100644
--- a/src/SMAPI/IModHelper.cs
+++ b/src/SMAPI/IModHelper.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using StardewModdingAPI.Events;
namespace StardewModdingAPI
{
@@ -12,15 +13,25 @@ namespace StardewModdingAPI
/// <summary>The full path to the mod's folder.</summary>
string DirectoryPath { get; }
+ /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary>
+ [Obsolete("This is an experimental interface which may change at any time. Don't depend on this for released mods.")]
+ IModEvents Events { get; }
+
/// <summary>An API for loading content assets.</summary>
IContentHelper Content { get; }
+ /// <summary>An API for checking and changing input state.</summary>
+ IInputHelper Input { get; }
+
/// <summary>Simplifies access to private game code.</summary>
IReflectionHelper Reflection { get; }
/// <summary>Metadata about loaded mods.</summary>
IModRegistry ModRegistry { get; }
+ /// <summary>Provides multiplayer utilities.</summary>
+ IMultiplayerHelper Multiplayer { get; }
+
/// <summary>An API for managing console commands.</summary>
ICommandHelper ConsoleCommands { get; }
diff --git a/src/SMAPI/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs
new file mode 100644
index 00000000..43a0ac95
--- /dev/null
+++ b/src/SMAPI/IMultiplayerHelper.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using StardewValley;
+
+namespace StardewModdingAPI
+{
+ /// <summary>Provides multiplayer utilities.</summary>
+ public interface IMultiplayerHelper : IModLinked
+ {
+ /// <summary>Get a new multiplayer ID.</summary>
+ long GetNewID();
+
+ /// <summary>Get the locations which are being actively synced from the host.</summary>
+ IEnumerable<GameLocation> GetActiveLocations();
+ }
+}
diff --git a/src/SMAPI/IPrivateField.cs b/src/SMAPI/IPrivateField.cs
deleted file mode 100644
index 42bf7d2e..00000000
--- a/src/SMAPI/IPrivateField.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-#if !STARDEW_VALLEY_1_3
-using System;
-using System.Reflection;
-
-namespace StardewModdingAPI
-{
- /// <summary>A private field obtained through reflection.</summary>
- /// <typeparam name="TValue">The field value type.</typeparam>
- [Obsolete("Use " + nameof(IReflectedField<TValue>) + " instead")]
- public interface IPrivateField<TValue>
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The reflection metadata.</summary>
- FieldInfo FieldInfo { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Get the field value.</summary>
- TValue GetValue();
-
- /// <summary>Set the field value.</summary>
- //// <param name="value">The value to set.</param>
- void SetValue(TValue value);
- }
-}
-#endif
diff --git a/src/SMAPI/IPrivateMethod.cs b/src/SMAPI/IPrivateMethod.cs
deleted file mode 100644
index c24db602..00000000
--- a/src/SMAPI/IPrivateMethod.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-#if !STARDEW_VALLEY_1_3
-using System;
-using System.Reflection;
-
-namespace StardewModdingAPI
-{
- /// <summary>A private method obtained through reflection.</summary>
- [Obsolete("Use " + nameof(IReflectedMethod) + " instead")]
- public interface IPrivateMethod
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The reflection metadata.</summary>
- MethodInfo MethodInfo { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Invoke the method.</summary>
- /// <typeparam name="TValue">The return type.</typeparam>
- /// <param name="arguments">The method arguments to pass in.</param>
- TValue Invoke<TValue>(params object[] arguments);
-
- /// <summary>Invoke the method.</summary>
- /// <param name="arguments">The method arguments to pass in.</param>
- void Invoke(params object[] arguments);
- }
-}
-#endif
diff --git a/src/SMAPI/IPrivateProperty.cs b/src/SMAPI/IPrivateProperty.cs
deleted file mode 100644
index a1b21a69..00000000
--- a/src/SMAPI/IPrivateProperty.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-#if !STARDEW_VALLEY_1_3
-using System;
-using System.Reflection;
-
-namespace StardewModdingAPI
-{
- /// <summary>A private property obtained through reflection.</summary>
- /// <typeparam name="TValue">The property value type.</typeparam>
- [Obsolete("Use " + nameof(IPrivateProperty<TValue>) + " instead")]
- public interface IPrivateProperty<TValue>
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The reflection metadata.</summary>
- PropertyInfo PropertyInfo { get; }
-
-
- /*********
- ** Public methods
- *********/
- /// <summary>Get the property value.</summary>
- TValue GetValue();
-
- /// <summary>Set the property value.</summary>
- //// <param name="value">The value to set.</param>
- void SetValue(TValue value);
- }
-}
-#endif
diff --git a/src/SMAPI/IReflectionHelper.cs b/src/SMAPI/IReflectionHelper.cs
index 60441471..a2b9eb32 100644
--- a/src/SMAPI/IReflectionHelper.cs
+++ b/src/SMAPI/IReflectionHelper.cs
@@ -47,74 +47,5 @@ namespace StardewModdingAPI
/// <param name="name">The field name.</param>
/// <param name="required">Whether to throw an exception if the field is not found.</param>
IReflectedMethod GetMethod(Type type, string name, bool required = true);
-
-#if !STARDEW_VALLEY_1_3
- /*****
- ** Obsolete
- *****/
- /// <summary>Get a private instance field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="obj">The object which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " instead")]
- IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true);
-
- /// <summary>Get a private static field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="type">The type which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " instead")]
- IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true);
-
- /// <summary>Get a private instance property.</summary>
- /// <typeparam name="TValue">The property type.</typeparam>
- /// <param name="obj">The object which has the property.</param>
- /// <param name="name">The property name.</param>
- /// <param name="required">Whether to throw an exception if the private property is not found.</param>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")]
- IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true);
-
- /// <summary>Get a private static property.</summary>
- /// <typeparam name="TValue">The property type.</typeparam>
- /// <param name="type">The type which has the property.</param>
- /// <param name="name">The property name.</param>
- /// <param name="required">Whether to throw an exception if the private property is not found.</param>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")]
- IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true);
-
- /// <summary>Get the value of a private instance field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="obj">The object which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- /// <remarks>This is a shortcut for <see cref="GetPrivateField{TValue}(object,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.</remarks>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " or " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")]
- TValue GetPrivateValue<TValue>(object obj, string name, bool required = true);
-
- /// <summary>Get the value of a private static field.</summary>
- /// <typeparam name="TValue">The field type.</typeparam>
- /// <param name="type">The type which has the field.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- /// <remarks>This is a shortcut for <see cref="GetPrivateField{TValue}(Type,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.</remarks>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetField) + " or " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetProperty) + " instead")]
- TValue GetPrivateValue<TValue>(Type type, string name, bool required = true);
-
- /// <summary>Get a private instance method.</summary>
- /// <param name="obj">The object which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetMethod) + " instead")]
- IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true);
-
- /// <summary>Get a private static method.</summary>
- /// <param name="type">The type which has the method.</param>
- /// <param name="name">The field name.</param>
- /// <param name="required">Whether to throw an exception if the private field is not found.</param>
- [Obsolete("Use " + nameof(IReflectionHelper) + "." + nameof(IReflectionHelper.GetMethod) + " instead")]
- IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true);
-#endif
}
}
diff --git a/src/SMAPI/LogLevel.cs b/src/SMAPI/LogLevel.cs
index 89647876..7987f82a 100644
--- a/src/SMAPI/LogLevel.cs
+++ b/src/SMAPI/LogLevel.cs
@@ -1,24 +1,26 @@
+using StardewModdingAPI.Internal.ConsoleWriting;
+
namespace StardewModdingAPI
{
/// <summary>The log severity levels.</summary>
public enum LogLevel
{
/// <summary>Tracing info intended for developers.</summary>
- Trace,
+ Trace = ConsoleLogLevel.Trace,
/// <summary>Troubleshooting info that may be relevant to the player.</summary>
- Debug,
+ Debug = ConsoleLogLevel.Debug,
/// <summary>Info relevant to the player. This should be used judiciously.</summary>
- Info,
+ Info = ConsoleLogLevel.Info,
/// <summary>An issue the player should be aware of. This should be used rarely.</summary>
- Warn,
+ Warn = ConsoleLogLevel.Warn,
/// <summary>A message indicating something went wrong.</summary>
- Error,
+ Error = ConsoleLogLevel.Error,
/// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary>
- Alert
+ Alert = ConsoleLogLevel.Alert
}
-} \ No newline at end of file
+}
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index e54e0286..12abeb10 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -80,17 +80,8 @@ namespace StardewModdingAPI.Metadata
** Buildings
****/
case "buildings\\houses": // Farm
-#if STARDEW_VALLEY_1_3
reflection.GetField<Texture2D>(typeof(Farm), nameof(Farm.houseTextures)).SetValue(content.Load<Texture2D>(key));
return true;
-#else
- {
- Farm farm = Game1.getFarm();
- if (farm == null)
- return false;
- return farm.houseTextures = content.Load<Texture2D>(key);
- }
-#endif
/****
** Content\Characters\Farmer
@@ -99,22 +90,14 @@ namespace StardewModdingAPI.Metadata
return FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key);
case "characters\\farmer\\farmer_base": // Farmer
- if (Game1.player == null || !Game1.player.isMale)
+ if (Game1.player == null || !Game1.player.IsMale)
return false;
-#if STARDEW_VALLEY_1_3
return Game1.player.FarmerRenderer = new FarmerRenderer(key);
-#else
- return Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key));
-#endif
case "characters\\farmer\\farmer_girl_base": // Farmer
- if (Game1.player == null || Game1.player.isMale)
+ if (Game1.player == null || Game1.player.IsMale)
return false;
-#if STARDEW_VALLEY_1_3
return Game1.player.FarmerRenderer = new FarmerRenderer(key);
-#else
- return Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key));
-#endif
case "characters\\farmer\\hairstyles": // Game1.loadContent
return FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key);
@@ -206,11 +189,6 @@ namespace StardewModdingAPI.Metadata
/****
** Content\Critters
****/
-#if !STARDEW_VALLEY_1_3
- case "tilesheets\\critters": // Criter.InitShared
- return Critter.critterTexture = content.Load<Texture2D>(key);
-#endif
-
case "tilesheets\\crops": // Game1.loadContent
return Game1.cropSpriteSheet = content.Load<Texture2D>(key);
@@ -265,11 +243,7 @@ namespace StardewModdingAPI.Metadata
Texture2D texture = content.Load<Texture2D>(key);
reflection.GetField<Texture2D>(titleMenu, "titleButtonsTexture").SetValue(texture);
foreach (TemporaryAnimatedSprite bird in reflection.GetField<List<TemporaryAnimatedSprite>>(titleMenu, "birds").GetValue())
-#if STARDEW_VALLEY_1_3
bird.texture = texture;
-#else
- bird.Texture = texture;
-#endif
return true;
}
return false;
@@ -284,12 +258,8 @@ namespace StardewModdingAPI.Metadata
return Game1.buffsIcons = content.Load<Texture2D>(key);
case "tilesheets\\bushes": // new Bush()
-#if STARDEW_VALLEY_1_3
reflection.GetField<Lazy<Texture2D>>(typeof(Bush), "texture").SetValue(new Lazy<Texture2D>(() => content.Load<Texture2D>(key)));
return true;
-#else
- return Bush.texture = content.Load<Texture2D>(key);
-#endif
case "tilesheets\\craftables": // Game1.loadContent
return Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key);
@@ -355,6 +325,10 @@ namespace StardewModdingAPI.Metadata
if (this.IsInFolder(key, "Portraits"))
return this.ReloadNpcPortraits(content, key);
+ // dynamic data
+ if (this.IsInFolder(key, "Characters\\schedules"))
+ return this.ReloadNpcSchedules(content, key);
+
return false;
}
@@ -363,7 +337,7 @@ namespace StardewModdingAPI.Metadata
** Private methods
*********/
/****
- ** Reload methods
+ ** Reload texture methods
****/
/// <summary>Reload the sprites for matching pets or horses.</summary>
/// <typeparam name="TAnimal">The animal type.</typeparam>
@@ -381,7 +355,7 @@ namespace StardewModdingAPI.Metadata
// update sprites
Texture2D texture = content.Load<Texture2D>(key);
foreach (TAnimal animal in animals)
- this.SetSpriteTexture(animal.sprite, texture);
+ this.SetSpriteTexture(animal.Sprite, texture);
return true;
}
@@ -402,16 +376,16 @@ namespace StardewModdingAPI.Metadata
foreach (FarmAnimal animal in animals)
{
// get expected key
- string expectedKey = animal.age < animal.ageWhenMature
- ? $"Baby{(animal.type == "Duck" ? "White Chicken" : animal.type)}"
- : animal.type;
- if (animal.showDifferentTextureWhenReadyForHarvest && animal.currentProduce <= 0)
+ string expectedKey = animal.age.Value < animal.ageWhenMature.Value
+ ? $"Baby{(animal.type.Value == "Duck" ? "White Chicken" : animal.type.Value)}"
+ : animal.type.Value;
+ if (animal.showDifferentTextureWhenReadyForHarvest.Value && animal.currentProduce.Value <= 0)
expectedKey = $"Sheared{expectedKey}";
expectedKey = $"Animals\\{expectedKey}";
// reload asset
if (expectedKey == key)
- this.SetSpriteTexture(animal.sprite, texture.Value);
+ this.SetSpriteTexture(animal.Sprite, texture.Value);
}
return texture.IsValueCreated;
}
@@ -427,7 +401,7 @@ namespace StardewModdingAPI.Metadata
Building[] buildings = Game1.locations
.OfType<BuildableGameLocation>()
.SelectMany(p => p.buildings)
- .Where(p => p.buildingType == type)
+ .Where(p => p.buildingType.Value == type)
.ToArray();
// reload buildings
@@ -435,11 +409,7 @@ namespace StardewModdingAPI.Metadata
{
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Building building in buildings)
-#if STARDEW_VALLEY_1_3
building.texture = texture;
-#else
- building.texture = texture.Value;
-#endif
return true;
}
return false;
@@ -460,16 +430,16 @@ namespace StardewModdingAPI.Metadata
(
from location in this.GetLocations()
from fence in location.Objects.Values.OfType<Fence>()
- where fenceType == 1
- ? fence.isGate
- : fence.whichType == fenceType
+ where
+ fence.whichType.Value == fenceType
+ || (fence.isGate.Value && fenceType == 1) // gates are hardcoded to draw fence type 1
select fence
)
.ToArray();
// update fence textures
foreach (Fence fence in fences)
- fence.reloadSprite();
+ this.Reflection.GetField<Lazy<Texture2D>>(fence, "fenceTexture").SetValue(new Lazy<Texture2D>(fence.loadFenceTexture));
return true;
}
@@ -482,7 +452,7 @@ namespace StardewModdingAPI.Metadata
{
// get NPCs
string name = this.GetNpcNameFromFileName(Path.GetFileName(key));
- NPC[] characters = this.GetCharacters().Where(npc => npc.name == name && npc.IsMonster == monster).ToArray();
+ NPC[] characters = this.GetCharacters().Where(npc => npc.Name == name && npc.IsMonster == monster).ToArray();
if (!characters.Any())
return false;
@@ -501,7 +471,7 @@ namespace StardewModdingAPI.Metadata
{
// get NPCs
string name = this.GetNpcNameFromFileName(Path.GetFileName(key));
- NPC[] villagers = this.GetCharacters().Where(npc => npc.name == name && npc.isVillager()).ToArray();
+ NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray();
if (!villagers.Any())
return false;
@@ -521,18 +491,14 @@ namespace StardewModdingAPI.Metadata
{
Tree[] trees = Game1.locations
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
- .Where(tree => tree.treeType == type)
+ .Where(tree => tree.treeType.Value == type)
.ToArray();
if (trees.Any())
{
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Tree tree in trees)
-#if STARDEW_VALLEY_1_3
this.Reflection.GetField<Lazy<Texture2D>>(tree, "texture").SetValue(texture);
-#else
- this.Reflection.GetField<Texture2D>(tree, "texture").SetValue(texture.Value);
-#endif
return true;
}
@@ -540,6 +506,38 @@ namespace StardewModdingAPI.Metadata
}
/****
+ ** Reload data methods
+ ****/
+ /// <summary>Reload the schedules for matching NPCs.</summary>
+ /// <param name="content">The content manager through which to reload the asset.</param>
+ /// <param name="key">The asset key to reload.</param>
+ /// <returns>Returns whether any assets were reloaded.</returns>
+ private bool ReloadNpcSchedules(LocalizedContentManager content, string key)
+ {
+ // get NPCs
+ string name = Path.GetFileName(key);
+ NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray();
+ if (!villagers.Any())
+ return false;
+
+ // update schedule
+ foreach (NPC villager in villagers)
+ {
+ // reload schedule
+ villager.Schedule = villager.getSchedule(Game1.dayOfMonth);
+
+ // switch to new schedule if needed
+ int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
+ if (lastScheduleTime != 0)
+ {
+ this.Reflection.GetField<int>(villager, "scheduleTimeToTry").SetValue(this.Reflection.GetField<int>(typeof(NPC), "NO_TRY").GetValue()); // use time that's passed in to checkSchedule
+ villager.checkSchedule(lastScheduleTime);
+ }
+ }
+ return true;
+ }
+
+ /****
** Helpers
****/
/// <summary>Reload the texture for an animated sprite.</summary>
@@ -547,11 +545,7 @@ namespace StardewModdingAPI.Metadata
/// <param name="texture">The texture to set.</param>
private void SetSpriteTexture(AnimatedSprite sprite, Texture2D texture)
{
-#if STARDEW_VALLEY_1_3
this.Reflection.GetField<Texture2D>(sprite, "spriteTexture").SetValue(texture);
-#else
- sprite.Texture = texture;
-#endif
}
/// <summary>Get an NPC name from the name of their file under <c>Content/Characters</c>.</summary>
@@ -605,8 +599,9 @@ namespace StardewModdingAPI.Metadata
{
foreach (Building building in buildableLocation.buildings)
{
- if (building.indoors != null)
- yield return building.indoors;
+ GameLocation indoors = building.indoors.Value;
+ if (indoors != null)
+ yield return indoors;
}
}
}
diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs
index 4960a458..2f0c1b15 100644
--- a/src/SMAPI/Metadata/InstructionMetadata.cs
+++ b/src/SMAPI/Metadata/InstructionMetadata.cs
@@ -1,14 +1,11 @@
using System.Collections.Generic;
using Microsoft.Xna.Framework.Graphics;
-using StardewModdingAPI.AssemblyRewriters;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.ModLoading.Finders;
using StardewModdingAPI.Framework.ModLoading.Rewriters;
+using StardewModdingAPI.Framework.RewriteFacades;
using StardewValley;
-#if STARDEW_VALLEY_1_3
-using SObject = StardewValley.Object;
-#endif
namespace StardewModdingAPI.Metadata
{
@@ -20,7 +17,7 @@ namespace StardewModdingAPI.Metadata
*********/
/// <summary>The assembly names to which to heuristically detect broken references.</summary>
/// <remarks>The current implementation only works correctly with assemblies that should always be present.</remarks>
- private readonly string[] ValidateReferencesToAssemblies = { "StardewModdingAPI", "Stardew Valley", "StardewValley" };
+ private readonly string[] ValidateReferencesToAssemblies = { "StardewModdingAPI", "Stardew Valley", "StardewValley", "Netcode" };
/*********
@@ -37,75 +34,21 @@ namespace StardewModdingAPI.Metadata
// rewrite for crossplatform compatibility
new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchMethods), onlyIfPlatformChanged: true),
-#if !STARDEW_VALLEY_1_3
- // rewrite for Stardew Valley 1.2
- new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.activeClickableMenu)),
- new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.currentMinigame)),
- new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.gameMode)),
- new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.player)),
- new FieldReplaceRewriter(typeof(Game1), "borderFont", nameof(Game1.smallFont)),
- new FieldReplaceRewriter(typeof(Game1), "smoothFont", nameof(Game1.smallFont)),
-
- // rewrite for SMAPI 1.9
- new TypeReferenceRewriter("StardewModdingAPI.Inheritance.ItemStackChange", typeof(ItemStackChange)),
-#endif
-
// rewrite for SMAPI 2.0
new VirtualEntryCallRemover(),
+ // rewrite for SMAPI 2.6 (types moved into SMAPI.Toolkit.CoreInterfaces)
+ new TypeReferenceRewriter("StardewModdingAPI.IManifest", typeof(IManifest), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"),
+ new TypeReferenceRewriter("StardewModdingAPI.IManifestContentPackFor", typeof(IManifestContentPackFor), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"),
+ new TypeReferenceRewriter("StardewModdingAPI.IManifestDependency", typeof(IManifestDependency), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"),
+ new TypeReferenceRewriter("StardewModdingAPI.ISemanticVersion", typeof(ISemanticVersion), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"),
+
// rewrite for Stardew Valley 1.3
-#if STARDEW_VALLEY_1_3
new StaticFieldToConstantRewriter<int>(typeof(Game1), "tileSize", Game1.tileSize),
-#endif
/****
** detect incompatible code
****/
- #if !STARDEW_VALLEY_1_3
- // detect changes in Stardew Valley 1.2
- new FieldFinder("StardewValley.Item", "set_Name", InstructionHandleResult.NotCompatible),
-
- // detect APIs removed in SMAPI 1.9
- new TypeFinder("StardewModdingAPI.Advanced.ConfigFile", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Advanced.IConfigFile", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Entities.SPlayer", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Extensions", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Inheritance.SGame", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Inheritance.SObject", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.LogWriter", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Manifest", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Version", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawDebug", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawTick", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderHudEventNoCheck", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderGuiEventNoCheck", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderHudEventNoCheck", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderGuiEventNoCheck", InstructionHandleResult.NotCompatible),
-
- // detect APIs removed in SMAPI 2.0
- new TypeFinder("StardewModdingAPI.Command", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Config", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Log", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GameEvents", "Initialize", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GameEvents", "LoadContent", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.GameEvents", "GameLoaded", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.PlayerEvents", "LoadedGame", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.PlayerEvents", "FarmerChanged", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.TimeEvents", "DayOfMonthChanged", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.TimeEvents", "YearOfGameChanged", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.TimeEvents", "SeasonOfYearChanged", InstructionHandleResult.NotCompatible),
- new EventFinder("StardewModdingAPI.Events.TimeEvents", "OnNewDay", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Events.EventArgsCommand", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Events.EventArgsFarmerChanged", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Events.EventArgsLoadedGameChanged", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Events.EventArgsNewDay", InstructionHandleResult.NotCompatible),
- new TypeFinder("StardewModdingAPI.Events.EventArgsStringChanged", InstructionHandleResult.NotCompatible),
- new PropertyFinder("StardewModdingAPI.Mod", "PathOnDisk", InstructionHandleResult.NotCompatible),
- new PropertyFinder("StardewModdingAPI.Mod", "BaseConfigPath", InstructionHandleResult.NotCompatible),
- new PropertyFinder("StardewModdingAPI.Mod", "PerSaveConfigFolder", InstructionHandleResult.NotCompatible),
- new PropertyFinder("StardewModdingAPI.Mod", "PerSaveConfigPath", InstructionHandleResult.NotCompatible),
- #endif
-
// detect broken code
new ReferenceToMissingMemberFinder(this.ValidateReferencesToAssemblies),
new ReferenceToMemberWithUnexpectedTypeFinder(this.ValidateReferencesToAssemblies),
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index ff4e9a50..6012b15a 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
@@ -7,29 +8,37 @@ using System.Net;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Security;
+using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
+using Microsoft.Xna.Framework.Input;
#if SMAPI_FOR_WINDOWS
-using System.Management;
using System.Windows.Forms;
#endif
using Newtonsoft.Json;
-using StardewModdingAPI.Common.Models;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Logging;
-using StardewModdingAPI.Framework.ModData;
using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading;
+using StardewModdingAPI.Framework.Patching;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Serialisation;
-using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Internal;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
+using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
+using Keys = Microsoft.Xna.Framework.Input.Keys;
using Monitor = StardewModdingAPI.Framework.Monitor;
using SObject = StardewValley.Object;
+using ThreadState = System.Threading.ThreadState;
namespace StardewModdingAPI
{
@@ -54,15 +63,14 @@ namespace StardewModdingAPI
/// <summary>Simplifies access to private game code.</summary>
private readonly Reflector Reflection = new Reflector();
+ /// <summary>The SMAPI configuration settings.</summary>
+ private readonly SConfig Settings;
+
/// <summary>The underlying game instance.</summary>
private SGame GameInstance;
/// <summary>The underlying content manager.</summary>
- private ContentCore ContentCore => this.GameInstance.ContentCore;
-
- /// <summary>The SMAPI configuration settings.</summary>
- /// <remarks>This is initialised after the game starts.</remarks>
- private SConfig Settings;
+ private ContentCoordinator ContentCore => this.GameInstance.ContentCore;
/// <summary>Tracks the installed mods.</summary>
/// <remarks>This is initialised after the game starts.</remarks>
@@ -72,10 +80,6 @@ namespace StardewModdingAPI
/// <remarks>This is initialised after the game starts.</remarks>
private DeprecationManager DeprecationManager;
- /// <summary>Manages console commands.</summary>
- /// <remarks>This is initialised after the game starts.</remarks>
- private CommandManager CommandManager;
-
/// <summary>Manages SMAPI events for mods.</summary>
private readonly EventManager EventManager;
@@ -89,11 +93,15 @@ namespace StardewModdingAPI
private readonly Regex[] SuppressConsolePatterns =
{
new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
- new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant)
+ new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Multiplayer auth success$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^DebugOutput: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant)
};
- /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
- private readonly JsonHelper JsonHelper = new JsonHelper();
+ /// <summary>The mod toolkit used for generic mod interactions.</summary>
+ private readonly ModToolkit Toolkit = new ModToolkit();
/*********
@@ -108,48 +116,57 @@ namespace StardewModdingAPI
// get flags from arguments
bool writeToConsole = !args.Contains("--no-terminal");
- // get log path from arguments
- string logPath = null;
- {
- int pathIndex = Array.LastIndexOf(args, "--log-path") + 1;
- if (pathIndex >= 1 && args.Length >= pathIndex)
- {
- logPath = args[pathIndex];
- if (!Path.IsPathRooted(logPath))
- logPath = Path.Combine(Constants.LogDir, logPath);
- }
- }
- if (string.IsNullOrWhiteSpace(logPath))
- logPath = Constants.DefaultLogPath;
-
// load SMAPI
- using (Program program = new Program(writeToConsole, logPath))
+ using (Program program = new Program(writeToConsole))
program.RunInteractively();
}
/// <summary>Construct an instance.</summary>
/// <param name="writeToConsole">Whether to output log messages to the console.</param>
- /// <param name="logPath">The full file path to which to write log messages.</param>
- public Program(bool writeToConsole, string logPath)
+ public Program(bool writeToConsole)
{
+ // init paths
+ this.VerifyPath(Constants.ModPath);
+ this.VerifyPath(Constants.LogDir);
+
+ // init log file
+ this.PurgeLogFiles();
+ string logPath = this.GetLogPath();
+
// init basics
+ this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
this.LogFile = new LogFileManager(logPath);
- this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = writeToConsole };
+ this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme)
+ {
+ WriteToConsole = writeToConsole,
+ ShowTraceInConsole = this.Settings.DeveloperMode,
+ ShowFullStampInConsole = this.Settings.DeveloperMode
+ };
this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
- // hook up events
- ContentEvents.Init(this.EventManager);
- ControlEvents.Init(this.EventManager);
- GameEvents.Init(this.EventManager);
- GraphicsEvents.Init(this.EventManager);
- InputEvents.Init(this.EventManager);
- LocationEvents.Init(this.EventManager);
- MenuEvents.Init(this.EventManager);
- MineEvents.Init(this.EventManager);
- PlayerEvents.Init(this.EventManager);
- SaveEvents.Init(this.EventManager);
- SpecialisedEvents.Init(this.EventManager);
- TimeEvents.Init(this.EventManager);
+ // init logging
+ this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
+ this.Monitor.Log($"Mods go here: {Constants.ModPath}");
+ this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace);
+
+ // validate game version
+ if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion))
+ {
+ this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error);
+ this.PressAnyKeyToExit();
+ return;
+ }
+ if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion))
+ {
+ this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error);
+ this.PressAnyKeyToExit();
+ return;
+ }
+
+ // apply game patches
+ new GamePatcher(this.Monitor).Apply(
+ // new GameLocationPatch()
+ );
}
/// <summary>Launch SMAPI.</summary>
@@ -159,28 +176,32 @@ namespace StardewModdingAPI
// initialise SMAPI
try
{
- // init logging
- this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info);
- this.Monitor.Log($"Mods go here: {Constants.ModPath}");
- this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace);
-
- // validate paths
- this.VerifyPath(Constants.ModPath);
- this.VerifyPath(Constants.LogDir);
-
- // validate game version
- if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion))
- {
- this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error);
- this.PressAnyKeyToExit();
- return;
- }
- if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion))
- {
- this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error);
- this.PressAnyKeyToExit();
- return;
- }
+ // hook up events
+ ContentEvents.Init(this.EventManager);
+ ControlEvents.Init(this.EventManager);
+ GameEvents.Init(this.EventManager);
+ GraphicsEvents.Init(this.EventManager);
+ InputEvents.Init(this.EventManager);
+ LocationEvents.Init(this.EventManager);
+ MenuEvents.Init(this.EventManager);
+ MineEvents.Init(this.EventManager);
+ MultiplayerEvents.Init(this.EventManager);
+ PlayerEvents.Init(this.EventManager);
+ SaveEvents.Init(this.EventManager);
+ SpecialisedEvents.Init(this.EventManager);
+ TimeEvents.Init(this.EventManager);
+
+ // init JSON parser
+ JsonConverter[] converters = {
+ new StringEnumConverter<Buttons>(),
+ new StringEnumConverter<Keys>(),
+ new StringEnumConverter<SButton>(),
+ new ColorConverter(),
+ new PointConverter(),
+ new RectangleConverter()
+ };
+ foreach (JsonConverter converter in converters)
+ this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter);
// add error handlers
#if SMAPI_FOR_WINDOWS
@@ -189,10 +210,13 @@ namespace StardewModdingAPI
#endif
AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
+ // add more leniant assembly resolvers
+ AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
+
// override game
SGame.MonitorDuringInitialisation = this.Monitor;
SGame.ReflectorDuringInitialisation = this.Reflection;
- this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.InitialiseAfterGameStart);
+ this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose);
StardewValley.Program.gamePtr = this.GameInstance;
// add exit handler
@@ -216,10 +240,6 @@ namespace StardewModdingAPI
}).Start();
// hook into game events
-#if SMAPI_FOR_WINDOWS
- ((Form)Control.FromHandle(this.GameInstance.Window.Handle)).FormClosing += (sender, args) => this.Dispose();
-#endif
- this.GameInstance.Exiting += (sender, e) => this.Dispose();
ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged();
// set window titles
@@ -233,11 +253,28 @@ namespace StardewModdingAPI
return;
}
+ // check update marker
+ if (File.Exists(Constants.UpdateMarker))
+ {
+ string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker);
+ if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound))
+ {
+ if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion))
+ {
+ this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error);
+ this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error);
+ this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info);
+ Console.ReadKey();
+ }
+ }
+ File.Delete(Constants.UpdateMarker);
+ }
+
// show details if game crashed during last session
if (File.Exists(Constants.FatalCrashMarker))
{
this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: http://community.playstarbound.com/threads/108375/.", LogLevel.Error);
- this.Monitor.Log($"If you ask for help, make sure to attach this file: {Constants.FatalCrashLog}", LogLevel.Error);
+ this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", LogLevel.Error);
this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info);
Console.ReadKey();
File.Delete(Constants.FatalCrashLog);
@@ -245,13 +282,19 @@ namespace StardewModdingAPI
}
// start game
- this.Monitor.Log("Starting game...", LogLevel.Trace);
+ this.Monitor.Log("Starting game...", LogLevel.Debug);
try
{
this.IsGameRunning = true;
StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window
this.GameInstance.Run();
}
+ catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor"))
+ {
+ this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error);
+ this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace);
+ this.PressAnyKeyToExit();
+ }
catch (Exception ex)
{
this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error);
@@ -266,12 +309,11 @@ namespace StardewModdingAPI
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
public void Dispose()
{
- this.Monitor.Log("Disposing...", LogLevel.Trace);
-
// skip if already disposed
if (this.IsDisposed)
return;
this.IsDisposed = true;
+ this.Monitor.Log("Disposing...", LogLevel.Trace);
// dispose mod data
foreach (IModMetadata mod in this.ModRegistry.GetAll())
@@ -293,6 +335,9 @@ namespace StardewModdingAPI
this.CancellationTokenSource?.Dispose();
this.GameInstance?.Dispose();
this.LogFile?.Dispose();
+
+ // end game (moved from Game1.OnExiting to let us clean up first)
+ Process.GetCurrentProcess().Kill();
}
@@ -309,14 +354,7 @@ namespace StardewModdingAPI
Console.ResetColor();
Program.PressAnyKeyToExit(showMessage: true);
}
-
- // get game assembly name
- const string gameAssemblyName =
-#if SMAPI_FOR_WINDOWS
- "Stardew Valley";
-#else
- "StardewValley";
-#endif
+ string gameAssemblyName = Constants.GameAssemblyName;
// game not present
if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null)
@@ -345,27 +383,21 @@ namespace StardewModdingAPI
private void InitialiseAfterGameStart()
{
// load settings
- this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
this.GameInstance.VerboseLogging = this.Settings.VerboseLogging;
// load core components
this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
- this.CommandManager = new CommandManager();
// redirect direct console output
{
- Monitor monitor = this.GetSecondaryMonitor("Console.Out");
+ Monitor monitor = this.GetSecondaryMonitor("game");
if (monitor.WriteToConsole)
this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message);
}
// add headers
if (this.Settings.DeveloperMode)
- {
- this.Monitor.ShowTraceInConsole = true;
- this.Monitor.ShowFullStampInConsole = true;
this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info);
- }
if (!this.Settings.CheckForUpdates)
this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn);
if (!this.Monitor.WriteToConsole)
@@ -377,7 +409,8 @@ namespace StardewModdingAPI
this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error);
// load mod data
- ModDatabase modDatabase = new ModDatabase(this.Settings.ModData, Constants.GetUpdateUrl);
+ ModToolkit toolkit = new ModToolkit();
+ ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath);
// load mods
{
@@ -385,14 +418,28 @@ namespace StardewModdingAPI
ModResolver resolver = new ModResolver();
// load manifests
- IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, this.JsonHelper, modDatabase).ToArray();
- resolver.ValidateManifests(mods, Constants.ApiVersion, Constants.GetUpdateUrl);
+ IModMetadata[] mods = resolver.ReadManifests(toolkit, Constants.ModPath, modDatabase).ToArray();
+ resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl);
// process dependencies
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
// load mods
- this.LoadMods(mods, this.JsonHelper, this.ContentCore, modDatabase);
+ this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
+
+ // write metadata file
+ if (this.Settings.DumpMetadata)
+ {
+ ModFolderExport export = new ModFolderExport
+ {
+ Exported = DateTime.UtcNow.ToString("O"),
+ ApiVersion = Constants.ApiVersion.ToString(),
+ GameVersion = Constants.GameVersion.ToString(),
+ ModFolderPath = Constants.ModPath,
+ Mods = mods
+ };
+ this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.metadata-dump.json"), export);
+ }
// check for updates
this.CheckForUpdatesAsync(mods);
@@ -430,8 +477,8 @@ namespace StardewModdingAPI
{
// prepare console
this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info);
- this.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand);
- this.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand);
+ this.GameInstance.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand);
+ this.GameInstance.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand);
// start handling command line input
Thread inputThread = new Thread(() =>
@@ -443,16 +490,9 @@ namespace StardewModdingAPI
if (string.IsNullOrWhiteSpace(input))
continue;
- // parse input
- try
- {
- if (!this.CommandManager.Trigger(input))
- this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error);
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error);
- }
+ // handle command
+ this.Monitor.LogUserInput(input);
+ this.GameInstance.CommandQueue.Enqueue(input);
}
});
inputThread.Start();
@@ -529,22 +569,36 @@ namespace StardewModdingAPI
new Thread(() =>
{
// create client
- WebApiClient client = new WebApiClient(this.Settings.WebApiBaseUrl, Constants.ApiVersion);
+ string url = this.Settings.WebApiBaseUrl;
+#if !SMAPI_FOR_WINDOWS
+ url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac
+#endif
+ WebApiClient client = new WebApiClient(url, Constants.ApiVersion);
this.Monitor.Log("Checking for updates...", LogLevel.Trace);
// check SMAPI version
+ ISemanticVersion updateFound = null;
try
{
- ModInfoModel response = client.GetModInfo($"GitHub:{this.Settings.GitHubProjectName}").Single().Value;
- if (response.Error != null)
+ ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value;
+ ISemanticVersion latestStable = response.Main?.Version;
+ ISemanticVersion latestBeta = response.Optional?.Version;
+
+ if (latestStable == null && response.Errors.Any())
{
this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn);
- this.Monitor.Log($"Error: {response.Error}");
+ this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}");
+ }
+ else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel))
+ {
+ updateFound = latestBeta;
+ this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert);
+ }
+ else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel))
+ {
+ updateFound = latestStable;
+ this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert);
}
- else if (this.IsValidUpdate(Constants.ApiVersion, new SemanticVersion(response.Version)))
- this.Monitor.Log($"You can update SMAPI to {response.Version}: {Constants.HomePageUrl}", LogLevel.Alert);
- else if (response.PreviewVersion != null && this.IsValidUpdate(Constants.ApiVersion, new SemanticVersion(response.PreviewVersion)))
- this.Monitor.Log($"You can update SMAPI to {response.PreviewVersion}: {Constants.HomePageUrl}", LogLevel.Alert);
else
this.Monitor.Log(" SMAPI okay.", LogLevel.Trace);
}
@@ -557,94 +611,85 @@ namespace StardewModdingAPI
);
}
+ // show update message on next launch
+ if (updateFound != null)
+ File.WriteAllText(Constants.UpdateMarker, updateFound.ToString());
+
// check mod versions
if (mods.Any())
{
try
{
- // prepare update keys
- Dictionary<string, IModMetadata[]> modsByKey =
- (
- from mod in mods
- where mod.Manifest?.UpdateKeys != null
- from key in mod.Manifest.UpdateKeys
- select new { key, mod }
- )
- .GroupBy(p => p.key, StringComparer.InvariantCultureIgnoreCase)
- .ToDictionary(
- group => group.Key,
- group => group.Select(p => p.mod).ToArray(),
- StringComparer.InvariantCultureIgnoreCase
- );
-
- // report update keys
+ HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
+
+ // prepare search model
+ List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>();
+ foreach (IModMetadata mod in mods)
{
- IModMetadata[] modsWithoutKeys = (
- from mod in mods
- where
- mod.Manifest != null
- && (mod.Manifest.UpdateKeys == null || !mod.Manifest.UpdateKeys.Any())
- && (mod.Manifest?.UniqueID != "SMAPI.ConsoleCommands" && mod.Manifest?.UniqueID != "SMAPI.TrainerMod")
- orderby mod.DisplayName
- select mod
- ).ToArray();
-
- string message = $"Checking {modsByKey.Count} mod update keys.";
- if (modsWithoutKeys.Any())
- message += $" {modsWithoutKeys.Length} mods have no update keys: {string.Join(", ", modsWithoutKeys.Select(p => p.DisplayName))}.";
- this.Monitor.Log($" {message}", LogLevel.Trace);
+ if (!mod.HasID())
+ continue;
+
+ string[] updateKeys = mod.Manifest.UpdateKeys ?? new string[0];
+ searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.Except(suppressUpdateChecks).ToArray()));
}
// fetch results
- var results =
- (
- from entry in client.GetModInfo(modsByKey.Keys.ToArray())
- from mod in modsByKey[entry.Key]
- orderby mod.DisplayName
- select new { entry.Key, Mod = mod, Info = entry.Value }
- )
- .ToArray();
-
- // extract latest versions
- IDictionary<IModMetadata, ModInfoModel> updatesByMod = new Dictionary<IModMetadata, ModInfoModel>();
- foreach (var result in results)
- {
- IModMetadata mod = result.Mod;
- ModInfoModel remoteInfo = result.Info;
+ this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace);
+ IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray());
- // handle error
- if (remoteInfo.Error != null)
- {
- this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: {remoteInfo.Error}", LogLevel.Trace);
+ // extract update alerts & errors
+ var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>();
+ var errors = new StringBuilder();
+ foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName))
+ {
+ // link to update-check data
+ if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result))
continue;
- }
+ mod.SetUpdateData(result);
- // normalise versions
- ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version;
- if (!SemanticVersion.TryParse(mod.DataRecord?.GetRemoteVersionForUpdateChecks(remoteInfo.Version) ?? remoteInfo.Version, out ISemanticVersion remoteVersion))
+ // handle errors
+ if (result.Errors != null && result.Errors.Any())
{
- this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: Mod has invalid version {remoteInfo.Version}", LogLevel.Trace);
- continue;
+ errors.AppendLine(result.Errors.Length == 1
+ ? $" {mod.DisplayName}: {result.Errors[0]}"
+ : $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}"
+ );
}
- // compare versions
- bool isUpdate = remoteVersion.IsNewerThan(localVersion);
- this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {remoteInfo.Version}" : "okay")}.");
- if (isUpdate)
- {
- if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || remoteVersion.IsNewerThan(other.Version))
- updatesByMod[mod] = remoteInfo;
- }
+ // parse versions
+ ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version;
+ ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version;
+ ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version;
+ ISemanticVersion unofficialVersion = result.Unofficial?.Version;
+
+ // show update alerts
+ if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true))
+ updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url));
+ else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease()))
+ updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url));
+ else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed))
+ updates.Add(Tuple.Create(mod, unofficialVersion, result.Unofficial?.Url));
}
- // output
- if (updatesByMod.Any())
+ // show update errors
+ if (errors.Length != 0)
+ this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace);
+
+ // show update alerts
+ if (updates.Any())
{
this.Monitor.Newline();
- this.Monitor.Log($"You can update {updatesByMod.Count} mod{(updatesByMod.Count != 1 ? "s" : "")}:", LogLevel.Alert);
- foreach (var entry in updatesByMod.OrderBy(p => p.Key.DisplayName))
- this.Monitor.Log($" {entry.Key.DisplayName} {entry.Value.Version}: {entry.Value.Url}", LogLevel.Alert);
+ this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert);
+ foreach (var entry in updates)
+ {
+ IModMetadata mod = entry.Item1;
+ ISemanticVersion newVersion = entry.Item2;
+ string newUrl = entry.Item3;
+ this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert);
+ }
}
+ else
+ this.Monitor.Log(" All mods up to date.", LogLevel.Trace);
}
catch (Exception ex)
{
@@ -661,22 +706,13 @@ namespace StardewModdingAPI
/// <summary>Get whether a given version should be offered to the user as an update.</summary>
/// <param name="currentVersion">The current semantic version.</param>
/// <param name="newVersion">The target semantic version.</param>
- private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion)
+ /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param>
+ private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel)
{
- // basic eligibility
- bool isNewer = newVersion.IsNewerThan(currentVersion);
- bool isPrerelease = newVersion.Build != null;
- bool isEquallyStable = !isPrerelease || currentVersion.Build != null; // don't update stable => prerelease
- if (!isNewer || !isEquallyStable)
- return false;
- if (!isPrerelease)
- return true;
-
- // prerelease eligible if same version (excluding prerelease tag)
return
- newVersion.MajorVersion == currentVersion.MajorVersion
- && newVersion.MinorVersion == currentVersion.MinorVersion
- && newVersion.PatchVersion == currentVersion.PatchVersion;
+ newVersion != null
+ && newVersion.IsNewerThan(currentVersion)
+ && (useBetaChannel || !newVersion.IsPrerelease());
}
/// <summary>Create a directory path if it doesn't exist.</summary>
@@ -699,32 +735,35 @@ namespace StardewModdingAPI
/// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param>
/// <param name="contentCore">The content manager to use for mod content.</param>
/// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
- private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCore contentCore, ModDatabase modDatabase)
+ private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase)
{
this.Monitor.Log("Loading mods...", LogLevel.Trace);
+ HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
IDictionary<IModMetadata, string[]> skippedMods = new Dictionary<IModMetadata, string[]>();
void TrackSkip(IModMetadata mod, string userReasonPhrase, string devReasonPhrase = null) => skippedMods[mod] = new[] { userReasonPhrase, devReasonPhrase };
// load content packs
foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack))
{
- // get basic info
- IManifest manifest = metadata.Manifest;
- this.Monitor.Log($"Loading {metadata.DisplayName} from {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)} (content pack)...", LogLevel.Trace);
+ this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)})...", LogLevel.Trace);
+
+ // show warning for missing update key
+ if (metadata.HasManifest() && !metadata.HasUpdateKeys())
+ metadata.SetWarning(ModWarning.NoUpdateKeys);
// validate status
if (metadata.Status == ModMetadataStatus.Failed)
{
- this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
+ this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
TrackSkip(metadata, metadata.Error);
continue;
}
// load mod as content pack
+ IManifest manifest = metadata.Manifest;
IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
- ContentManagerShim contentManager = this.ContentCore.CreateContentManager($"Mods.{metadata.Manifest.UniqueID}", metadata.DirectoryPath);
- IContentHelper contentHelper = new ContentHelper(this.ContentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
+ IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper);
metadata.SetMod(contentPack, monitor);
this.ModRegistry.Add(metadata);
@@ -743,100 +782,103 @@ namespace StardewModdingAPI
StringComparer.InvariantCultureIgnoreCase
);
- // get assembly loaders
- AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor, this.Settings.DeveloperMode);
- AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name);
- InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory();
-
- // load from metadata
- foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack))
+ // load mods from metadata
+ using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor))
{
- // get basic info
- IManifest manifest = metadata.Manifest;
- this.Monitor.Log(metadata.Manifest?.EntryDll != null
- ? $"Loading {metadata.DisplayName} from {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll}..." // don't use Path.Combine here, since EntryDLL might not be valid
- : $"Loading {metadata.DisplayName}...", LogLevel.Trace);
-
- // validate status
- if (metadata.Status == ModMetadataStatus.Failed)
+ InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory();
+ foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack))
{
- this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
- TrackSkip(metadata, metadata.Error);
- continue;
- }
-
- // load mod
- string assemblyPath = metadata.Manifest?.EntryDll != null
- ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll)
- : null;
- Assembly modAssembly;
- try
- {
- modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible);
- }
- catch (IncompatibleInstructionException) // details already in trace logs
- {
- string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray();
+ // get basic info
+ IManifest manifest = metadata.Manifest;
+ this.Monitor.Log(metadata.Manifest?.EntryDll != null
+ ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid
+ : $" {metadata.DisplayName}...", LogLevel.Trace);
+
+ // show warnings
+ if (metadata.HasManifest() && !metadata.HasUpdateKeys() && !suppressUpdateChecks.Contains(metadata.Manifest.UniqueID))
+ metadata.SetWarning(ModWarning.NoUpdateKeys);
+
+ // validate status
+ if (metadata.Status == ModMetadataStatus.Failed)
+ {
+ this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
+ TrackSkip(metadata, metadata.Error);
+ continue;
+ }
- TrackSkip(metadata, $"it's outdated. Please check for a new version at {string.Join(" or ", updateUrls)}.");
- continue;
- }
- catch (SAssemblyLoadFailedException ex)
- {
- TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}");
- continue;
- }
- catch (Exception ex)
- {
- TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}");
- continue;
- }
+ // load mod
+ string assemblyPath = metadata.Manifest?.EntryDll != null
+ ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll)
+ : null;
+ Assembly modAssembly;
+ try
+ {
+ modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible);
+ }
+ catch (IncompatibleInstructionException) // details already in trace logs
+ {
+ string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray();
- // initialise mod
- try
- {
- // get content packs
- if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks))
- contentPacks = new IContentPack[0];
+ TrackSkip(metadata, $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}.");
+ continue;
+ }
+ catch (SAssemblyLoadFailedException ex)
+ {
+ TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}");
+ continue;
+ }
+ catch (Exception ex)
+ {
+ TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}");
+ continue;
+ }
- // init mod helpers
- IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
- IModHelper modHelper;
+ // initialise mod
+ try
{
- ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager);
- ContentManagerShim contentManager = this.ContentCore.CreateContentManager($"Mods.{metadata.Manifest.UniqueID}", metadata.DirectoryPath);
- IContentHelper contentHelper = new ContentHelper(contentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
- IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager);
- IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
- ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language);
-
- IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest)
- {
- IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name);
- ContentManagerShim packContentManager = this.ContentCore.CreateContentManager($"Mods.{packManifest.UniqueID}", packDirPath);
- IContentHelper packContentHelper = new ContentHelper(contentCore, packContentManager, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
- return new ContentPack(packDirPath, packManifest, packContentHelper, this.JsonHelper);
- }
+ // get mod instance
+ if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod))
+ continue;
- modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager);
- }
+ // get content packs
+ if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks))
+ contentPacks = new IContentPack[0];
- // get mod instance
- if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod))
- continue;
+ // init mod helpers
+ IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
+ IModHelper modHelper;
+ {
+ IModEvents events = new ModEvents(metadata, this.EventManager);
+ ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.GameInstance.CommandManager);
+ IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
+ IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager);
+ IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
+ IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer);
+ ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language);
+
+ IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest)
+ {
+ IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name);
+ IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
+ return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper);
+ }
+
+ modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager);
+ }
- // init mod
- mod.ModManifest = manifest;
- mod.Helper = modHelper;
- mod.Monitor = monitor;
+ // init mod
+ mod.ModManifest = manifest;
+ mod.Helper = modHelper;
+ mod.Monitor = monitor;
- // track mod
- metadata.SetMod(mod);
- this.ModRegistry.Add(metadata);
- }
- catch (Exception ex)
- {
- TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}");
+ // track mod
+ metadata.SetMod(mod);
+ this.ModRegistry.Add(metadata);
+ }
+ catch (Exception ex)
+ {
+ TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}");
+ }
}
}
}
@@ -894,6 +936,28 @@ namespace StardewModdingAPI
this.Monitor.Newline();
}
+ // log warnings
+ {
+ IModMetadata[] modsWithWarnings = this.ModRegistry.GetAll().Where(p => p.Warnings != ModWarning.None).ToArray();
+ if (modsWithWarnings.Any())
+ {
+ this.Monitor.Log($"Found issues with {modsWithWarnings.Length} mods:", LogLevel.Warn);
+ foreach (IModMetadata metadata in modsWithWarnings)
+ {
+ string[] warnings = this.GetWarningText(metadata.Warnings).ToArray();
+ if (warnings.Length == 1)
+ this.Monitor.Log($" {metadata.DisplayName} {warnings[0]}", LogLevel.Warn);
+ else
+ {
+ this.Monitor.Log($" {metadata.DisplayName}:", LogLevel.Warn);
+ foreach (string warning in warnings)
+ this.Monitor.Log(" - " + warning, LogLevel.Warn);
+ }
+ }
+ this.Monitor.Newline();
+ }
+ }
+
// initialise translations
this.ReloadTranslations(loadedMods);
@@ -983,6 +1047,25 @@ namespace StardewModdingAPI
this.ModRegistry.AreAllModsInitialised = true;
}
+ /// <summary>Get the warning text for a mod warning bit mask.</summary>
+ /// <param name="mask">The mod warning bit mask.</param>
+ private IEnumerable<string> GetWarningText(ModWarning mask)
+ {
+ if (mask.HasFlag(ModWarning.BrokenCodeLoaded))
+ yield return "has broken code, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.";
+ if (mask.HasFlag(ModWarning.ChangesSaveSerialiser))
+ yield return "accesses the save serialiser and may break your saves.";
+ if (mask.HasFlag(ModWarning.PatchesGame))
+ yield return "patches the game. This may cause errors or bugs in-game. If you have issues, try removing this mod first.";
+ if (mask.HasFlag(ModWarning.UsesUnvalidatedUpdateTick))
+ yield return "bypasses normal SMAPI event protections. This may cause errors or save corruption. If you have issues, try removing this mod first.";
+ if (mask.HasFlag(ModWarning.UsesDynamic))
+ yield return "uses the 'dynamic' keyword. This won't work on Linux/Mac.";
+ if (mask.HasFlag(ModWarning.NoUpdateKeys))
+ yield return "has no update keys in its manifest. SMAPI won't show update alerts for this mod.";
+
+ }
+
/// <summary>Load a mod's entry class.</summary>
/// <param name="modAssembly">The mod assembly.</param>
/// <param name="onError">A callback invoked when loading fails.</param>
@@ -1019,7 +1102,7 @@ namespace StardewModdingAPI
/// <param name="mods">The mods for which to reload translations.</param>
private void ReloadTranslations(IEnumerable<IModMetadata> mods)
{
- JsonHelper jsonHelper = new JsonHelper();
+ JsonHelper jsonHelper = this.Toolkit.JsonHelper;
foreach (IModMetadata metadata in mods)
{
if (metadata.IsContentPack)
@@ -1045,8 +1128,17 @@ namespace StardewModdingAPI
}
// validate translations
- foreach (string locale in translations.Keys)
+ foreach (string locale in translations.Keys.ToArray())
{
+ // skip empty files
+ if (translations[locale] == null || !translations[locale].Keys.Any())
+ {
+ metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn);
+ translations.Remove(locale);
+ continue;
+ }
+
+ // handle duplicates
HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
foreach (string key in translations[locale].Keys.ToArray())
@@ -1057,7 +1149,6 @@ namespace StardewModdingAPI
translations[locale].Remove(key);
}
}
-
if (duplicateKeys.Any())
metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn);
}
@@ -1078,7 +1169,7 @@ namespace StardewModdingAPI
case "help":
if (arguments.Any())
{
- Command result = this.CommandManager.Get(arguments[0]);
+ Command result = this.GameInstance.CommandManager.Get(arguments[0]);
if (result == null)
this.Monitor.Log("There's no command with that name.", LogLevel.Error);
else
@@ -1087,7 +1178,7 @@ namespace StardewModdingAPI
else
{
string message = "The following commands are registered:\n";
- IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray();
+ IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray();
foreach (var group in groups)
{
string modName = group.Key;
@@ -1148,7 +1239,7 @@ namespace StardewModdingAPI
/// <param name="name">The name of the module which will log messages with this instance.</param>
private Monitor GetSecondaryMonitor(string name)
{
- return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource)
+ return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme)
{
WriteToConsole = this.Monitor.WriteToConsole,
ShowTraceInConsole = this.Settings.DeveloperMode,
@@ -1156,24 +1247,6 @@ namespace StardewModdingAPI
};
}
- /// <summary>Get a human-readable name for the current platform.</summary>
- [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")]
- private string GetFriendlyPlatformName()
- {
-#if SMAPI_FOR_WINDOWS
- try
- {
- return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem")
- .Get()
- .Cast<ManagementObject>()
- .Select(entry => entry.GetPropertyValue("Caption").ToString())
- .FirstOrDefault();
- }
- catch { }
-#endif
- return Environment.OSVersion.ToString();
- }
-
/// <summary>Log a message if verbose mode is enabled.</summary>
/// <param name="message">The message to log.</param>
private void VerboseLog(string message)
@@ -1181,5 +1254,50 @@ namespace StardewModdingAPI
if (this.Settings.VerboseLogging)
this.Monitor.Log(message, LogLevel.Trace);
}
+
+ /// <summary>Get the absolute path to the next available log file.</summary>
+ private string GetLogPath()
+ {
+ // default path
+ {
+ FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.{Constants.LogNameExtension}"));
+ if (!defaultFile.Exists)
+ return defaultFile.FullName;
+ }
+
+ // get first disambiguated path
+ for (int i = 2; i < int.MaxValue; i++)
+ {
+ FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.player-{i}.{Constants.LogNameExtension}"));
+ if (!file.Exists)
+ return file.FullName;
+ }
+
+ // should never happen
+ throw new InvalidOperationException("Could not find an available log path.");
+ }
+
+ /// <summary>Delete all log files created by SMAPI.</summary>
+ private void PurgeLogFiles()
+ {
+ DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir);
+ if (!logsDir.Exists)
+ return;
+
+ foreach (FileInfo logFile in logsDir.EnumerateFiles())
+ {
+ if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase))
+ {
+ try
+ {
+ FileUtilities.ForceDelete(logFile);
+ }
+ catch (IOException)
+ {
+ // ignore file if it's in use
+ }
+ }
+ }
+ }
}
}
diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs
index 4826c947..587ff286 100644
--- a/src/SMAPI/SemanticVersion.cs
+++ b/src/SMAPI/SemanticVersion.cs
@@ -1,6 +1,5 @@
using System;
using Newtonsoft.Json;
-using StardewModdingAPI.Common;
namespace StardewModdingAPI
{
@@ -11,23 +10,23 @@ namespace StardewModdingAPI
** Properties
*********/
/// <summary>The underlying semantic version implementation.</summary>
- private readonly SemanticVersionImpl Version;
+ private readonly ISemanticVersion Version;
/*********
** Accessors
*********/
/// <summary>The major version incremented for major API changes.</summary>
- public int MajorVersion => this.Version.Major;
+ public int MajorVersion => this.Version.MajorVersion;
/// <summary>The minor version incremented for backwards-compatible changes.</summary>
- public int MinorVersion => this.Version.Minor;
+ public int MinorVersion => this.Version.MinorVersion;
/// <summary>The patch version for backwards-compatible bug fixes.</summary>
- public int PatchVersion => this.Version.Patch;
+ public int PatchVersion => this.Version.PatchVersion;
/// <summary>An optional build tag.</summary>
- public string Build => this.Version.Tag;
+ public string Build => this.Version.Build;
/*********
@@ -40,20 +39,33 @@ namespace StardewModdingAPI
/// <param name="build">An optional build tag.</param>
[JsonConstructor]
public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string build = null)
- : this(new SemanticVersionImpl(majorVersion, minorVersion, patchVersion, build)) { }
+ : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, build)) { }
/// <summary>Construct an instance.</summary>
/// <param name="version">The semantic version string.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
/// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
public SemanticVersion(string version)
- : this(new SemanticVersionImpl(version)) { }
+ : this(new Toolkit.SemanticVersion(version)) { }
/// <summary>Construct an instance.</summary>
/// <param name="version">The assembly version.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
public SemanticVersion(Version version)
- : this(new SemanticVersionImpl(version)) { }
+ : this(new Toolkit.SemanticVersion(version)) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The underlying semantic version implementation.</param>
+ internal SemanticVersion(ISemanticVersion version)
+ {
+ this.Version = version;
+ }
+
+ /// <summary>Whether this is a pre-release version.</summary>
+ public bool IsPrerelease()
+ {
+ return this.Version.IsPrerelease();
+ }
/// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
/// <param name="other">The version to compare with this instance.</param>
@@ -61,14 +73,14 @@ namespace StardewModdingAPI
/// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks>
public int CompareTo(ISemanticVersion other)
{
- return this.Version.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.Build);
+ return this.Version.CompareTo(other);
}
/// <summary>Get whether this version is older than the specified version.</summary>
/// <param name="other">The version to compare with this instance.</param>
public bool IsOlderThan(ISemanticVersion other)
{
- return this.CompareTo(other) < 0;
+ return this.Version.IsOlderThan(other);
}
/// <summary>Get whether this version is older than the specified version.</summary>
@@ -76,14 +88,14 @@ namespace StardewModdingAPI
/// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
public bool IsOlderThan(string other)
{
- return this.IsOlderThan(new SemanticVersion(other));
+ return this.Version.IsOlderThan(other);
}
/// <summary>Get whether this version is newer than the specified version.</summary>
/// <param name="other">The version to compare with this instance.</param>
public bool IsNewerThan(ISemanticVersion other)
{
- return this.CompareTo(other) > 0;
+ return this.Version.IsNewerThan(other);
}
/// <summary>Get whether this version is newer than the specified version.</summary>
@@ -91,7 +103,7 @@ namespace StardewModdingAPI
/// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
public bool IsNewerThan(string other)
{
- return this.IsNewerThan(new SemanticVersion(other));
+ return this.Version.IsNewerThan(other);
}
/// <summary>Get whether this version is between two specified versions (inclusively).</summary>
@@ -99,7 +111,7 @@ namespace StardewModdingAPI
/// <param name="max">The maximum version.</param>
public bool IsBetween(ISemanticVersion min, ISemanticVersion max)
{
- return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0;
+ return this.Version.IsBetween(min, max);
}
/// <summary>Get whether this version is between two specified versions (inclusively).</summary>
@@ -108,7 +120,7 @@ namespace StardewModdingAPI
/// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception>
public bool IsBetween(string min, string max)
{
- return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max));
+ return this.Version.IsBetween(min, max);
}
/// <summary>Indicates whether the current object is equal to another object of the same type.</summary>
@@ -131,7 +143,7 @@ namespace StardewModdingAPI
/// <returns>Returns whether parsing the version succeeded.</returns>
internal static bool TryParse(string version, out ISemanticVersion parsed)
{
- if (SemanticVersionImpl.TryParse(version, out SemanticVersionImpl versionImpl))
+ if (Toolkit.SemanticVersion.TryParse(version, out ISemanticVersion versionImpl))
{
parsed = new SemanticVersion(versionImpl);
return true;
@@ -140,17 +152,5 @@ namespace StardewModdingAPI
parsed = null;
return false;
}
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>Construct an instance.</summary>
- /// <param name="version">The underlying semantic version implementation.</param>
- private SemanticVersion(SemanticVersionImpl version)
- {
- this.Version = version;
- }
-
}
}
diff --git a/src/SMAPI/StardewModdingAPI.config.json b/src/SMAPI/StardewModdingAPI.config.json
index f7082e96..115997ba 100644
--- a/src/SMAPI/StardewModdingAPI.config.json
+++ b/src/SMAPI/StardewModdingAPI.config.json
@@ -22,6 +22,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha
"CheckForUpdates": true,
/**
+ * Whether SMAPI should show newer beta versions as an available update. If not specified, SMAPI
+ * will only show beta updates if the current version is beta.
+ */
+ //"UseBetaChannel": true,
+
+ /**
* SMAPI's GitHub project name, used to perform update checks.
*/
"GitHubProjectName": "Pathoschild/SMAPI",
@@ -39,1837 +45,24 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha
"VerboseLogging": false,
/**
- * Metadata about some SMAPI mods used in compatibility, update, and dependency checks. This
- * field shouldn't be edited by players in most cases.
- *
- * Standard fields
- * ===============
- * The predefined fields are documented below (only 'ID' is required). Each entry's key is the
- * default display name for the mod if one isn't available (e.g. in dependency checks).
- *
- * - ID: the mod's latest unique ID (if any).
- *
- * - FormerIDs: uniquely identifies the mod across multiple versions, and supports matching
- * other fields if no ID was specified. This doesn't include the latest ID, if any.
- * Format rules:
- * 1. If the mod's ID changed over time, multiple variants can be separated by the '|'
- * character.
- * 2. Each variant can take one of two forms:
- * - A simple string matching the mod's UniqueID value.
- * - A JSON structure containing any of four manifest fields (ID, Name, Author, and
- * EntryDll) to match.
- *
- * - MapLocalVersions and MapRemoteVersions correct local manifest versions and remote versions
- * during update checks. For example, if the API returns version '1.1-1078' where '1078' is
- * intended to be a build number, MapRemoteVersions can map it to '1.1' when comparing to the
- * mod's current version. This is only meant to support legacy mods with injected update keys.
- *
- * Versioned metadata
- * ==================
- * Each record can also specify extra metadata using the field keys below.
- *
- * Each key consists of a field name prefixed with any combination of version range and 'Default',
- * separated by pipes (whitespace trimmed). For example, 'UpdateKey' will always override,
- * 'Default | UpdateKey' will only override if the mod has no update keys, and
- * '~1.1 | Default | Name' will do the same up to version 1.1.
- *
- * The version format is 'min~max' (where either side can be blank for unbounded), or a single
- * version number.
- *
- * These are the valid field names:
- *
- * - UpdateKey: the update key to set in the mod's manifest. This is used to enable update
- * checks for older mods that haven't been updated to use it yet.
- *
- * - Status: overrides compatibility checks. The possible values are Obsolete (SMAPI won't load
- * it because the mod should no longer be used), AssumeBroken (SMAPI won't load it because
- * the specified version isn't compatible), or AssumeCompatible (SMAPI will try to load it
- * even if it detects incompatible code).
- *
- * - StatusReasonPhrase: a message to show to the player explaining why the mod can't be loaded
- * (if applicable). If blank, will default to a generic not-compatible message.
- *
- * - AlternativeUrl: a URL where the player can find an unofficial update or alternative if the
- * mod is no longer compatible.
+ * Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod
+ * metadata for detected mods. This is only needed when troubleshooting some cases.
*/
- "ModData": {
- "AccessChestAnywhere": {
- "ID": "AccessChestAnywhere",
- "MapLocalVersions": { "1.1-1078": "1.1" },
- "Default | UpdateKey": "Nexus:257",
- "~1.1 | Status": "AssumeBroken"
- },
-
- "AdjustArtisanPrices": {
- "ID": "ThatNorthernMonkey.AdjustArtisanPrices",
- "FormerIDs": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", // changed in 0.0.2-pathoschild-update
- "MapRemoteVersions": { "0.01": "0.0.1" },
- "Default | UpdateKey": "Chucklefish:3532",
- "~0.0.1 | Status": "AssumeBroken"
- },
-
- "Adjust Monster": {
- "ID": "mmanlapat.AdjustMonster",
- "Default | UpdateKey": "Nexus:1161"
- },
-
- "Advanced Location Loader": {
- "ID": "Entoarox.AdvancedLocationLoader",
- "~1.3.7 | UpdateKey": "Chucklefish:3619", // only enable update checks up to 1.3.7 by request (has its own update-check feature)
- "~1.2.10 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Adventure Shop Inventory": {
- "ID": "HammurabiAdventureShopInventory",
- "Default | UpdateKey": "Chucklefish:4608"
- },
-
- "AgingMod": {
- "ID": "skn.AgingMod",
- "Default | UpdateKey": "Nexus:1129",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "All Crops All Seasons": {
- "ID": "cantorsdust.AllCropsAllSeasons",
- "FormerIDs": "29ee8246-d67b-4242-a340-35a9ae0d5dd7 | community.AllCropsAllSeasons", // changed in 1.3 and 1.5
- "Default | UpdateKey": "Nexus:170"
- },
-
- "All Professions": {
- "ID": "cantorsdust.AllProfessions",
- "FormerIDs": "8c37b1a7-4bfb-4916-9d8a-9533e6363ea3 | community.AllProfessions", // changed in 1.2 and 1.3.1
- "Default | UpdateKey": "Nexus:174"
- },
-
- "Almighty Tool": {
- "ID": "439",
- "FormerIDs": "{EntryDll: 'AlmightyTool.dll'}", // changed in 1.2.1
- "MapRemoteVersions": { "1.21": "1.2.1" },
- "Default | UpdateKey": "Nexus:439",
- "~1.1.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Animal Husbandry": {
- "ID": "DIGUS.ANIMALHUSBANDRYMOD",
- "FormerIDs": "DIGUS.BUTCHER", // changed in 2.0.1
- "Default | UpdateKey": "Nexus:1538"
- },
-
- "Animal Mood Fix": {
- "ID": "GPeters-AnimalMoodFix",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2."
- },
-
- "Animal Sitter": {
- "ID": "jwdred.AnimalSitter",
- "FormerIDs": "{EntryDll: 'AnimalSitter.dll'}", // changed in 1.0.9
- "Default | UpdateKey": "Nexus:581",
- "~1.0.8 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Ashley Mod": {
- "FormerIDs": "{EntryDll: 'AshleyMod.dll'}",
- "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "A Tapper's Dream": {
- "ID": "ddde5195-8f85-4061-90cc-0d4fd5459358",
- "Default | UpdateKey": "Nexus:260",
- "~1.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Auto Animal Doors": {
- "ID": "AaronTaggart.AutoAnimalDoors",
- "Default | UpdateKey": "Nexus:1019"
- },
-
- "Auto-Eat": {
- "ID": "Permamiss.AutoEat",
- "FormerIDs": "BALANCEMOD_AutoEat", // changed in 1.1.1
- "Default | UpdateKey": "Nexus:643"
- },
-
- "AutoFish": {
- "ID": "WhiteMind.AF",
- "Default | UpdateKey": "Nexus:1895"
- },
-
- "AutoGate": {
- "ID": "AutoGate",
- "Default | UpdateKey": "Nexus:820"
- },
-
- "Automate": {
- "ID": "Pathoschild.Automate",
- "Default | UpdateKey": "Nexus:1063"
- },
-
- "Automated Doors": {
- "ID": "azah.automated-doors",
- "FormerIDs": "1abcfa07-2cf4-4dc3-a6e9-6068b642112b", // changed in 1.4.1
- "Default | UpdateKey": "GitHub:azah/AutomatedDoors" // added in 1.4.2
- },
-
- "AutoSpeed": {
- "ID": "Omegasis.AutoSpeed",
- "FormerIDs": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'AutoSpeed'}", // changed in 1.4; disambiguate from other Alpha_Omegasis mods
- "Default | UpdateKey": "Nexus:443" // added in 1.4.1
- },
-
- "Basic Sprinklers Improved": {
- "ID": "lrsk_sdvm_bsi.0117171308",
- "MapRemoteVersions": { "1.0.2": "1.0.1-release" }, // manifest not updated
- "Default | UpdateKey": "Nexus:833"
- },
-
- "Better Hay": {
- "ID": "cat.betterhay",
- "Default | UpdateKey": "Nexus:1430"
- },
-
- "Better Quality More Seasons": {
- "ID": "SB_BQMS",
- "Default | UpdateKey": "Nexus:935"
- },
-
- "Better Quarry": {
- "ID": "BetterQuarry",
- "Default | UpdateKey": "Nexus:771"
- },
-
- "Better Ranching": {
- "ID": "BetterRanching",
- "Default | UpdateKey": "Nexus:859"
- },
-
- "Better Shipping Box": {
- "ID": "Kithio:BetterShippingBox",
- "MapLocalVersions": { "1.0.1": "1.0.2" },
- "Default | UpdateKey": "Chucklefish:4302",
- "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Better Sprinklers": {
- "ID": "Speeder.BetterSprinklers",
- "FormerIDs": "SPDSprinklersMod", // changed in 2.3
- "Default | UpdateKey": "Nexus:41",
- "~2.3.1-pathoschild-update | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Billboard Anywhere": {
- "ID": "Omegasis.BillboardAnywhere",
- "FormerIDs": "{ID:'7ad4f6f7-c3de-4729-a40f-7a11d2b2a358', Name:'Billboard Anywhere'}", // changed in 1.4; disambiguate from other mods by Alpha_Omegasis
- "Default | UpdateKey": "Nexus:492" // added in 1.4.1
- },
-
- "Birthday Mail": {
- "ID": "KathrynHazuka.BirthdayMail",
- "FormerIDs": "005e02dc-d900-425c-9c68-1ff55c5a295d", // changed in 1.2.3-pathoschild-update
- "Default | UpdateKey": "Nexus:276",
- "~1.2.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Breed Like Rabbits": {
- "ID": "dycedarger.breedlikerabbits",
- "Default | UpdateKey": "Nexus:948"
- },
-
- "Build Endurance": {
- "ID": "Omegasis.BuildEndurance",
- "FormerIDs": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'BuildEndurance'}", // changed in 1.4; disambiguate from other Alpha_Omegasis mods
- "Default | UpdateKey": "Nexus:445", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Build Health": {
- "ID": "Omegasis.BuildHealth",
- "FormerIDs": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'BuildHealth'}", // changed in 1.4; disambiguate from other Alpha_Omegasis mods
- "Default | UpdateKey": "Nexus:446", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Buy Cooking Recipes": {
- "ID": "Denifia.BuyRecipes",
- "Default | UpdateKey": "Nexus:1126", // added in 1.0.1 (2017-10-04)
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Buy Back Collectables": {
- "ID": "Omegasis.BuyBackCollectables",
- "FormerIDs": "BuyBackCollectables", // changed in 1.4
- "Default | UpdateKey": "Nexus:507", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Carry Chest": {
- "ID": "spacechase0.CarryChest",
- "Default | UpdateKey": "Nexus:1333"
- },
-
- "Casks Anywhere": {
- "ID": "CasksAnywhere",
- "MapLocalVersions": { "1.1-alpha": "1.1" },
- "Default | UpdateKey": "Nexus:878"
- },
-
- "Categorize Chests": {
- "ID": "CategorizeChests",
- "Default | UpdateKey": "Nexus:1300"
- },
-
- "Chefs Closet": {
- "ID": "Duder.ChefsCloset",
- "MapLocalVersions": { "1.3-1": "1.3" },
- "Default | UpdateKey": "Nexus:1030"
- },
-
- "Chest Label System": {
- "ID": "Speeder.ChestLabel",
- "FormerIDs": "SPDChestLabel", // changed in 1.5.1-pathoschild-update
- "Default | UpdateKey": "Nexus:242",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.1
- },
-
- "Chest Pooling": {
- "ID": "mralbobo.ChestPooling",
- "FormerIDs": "{EntryDll: 'ChestPooling.dll'}", // changed in 1.3
- "Default | UpdateKey": "GitHub:mralbobo/stardew-chest-pooling",
- "~1.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Chests Anywhere": {
- "ID": "Pathoschild.ChestsAnywhere",
- "FormerIDs": "ChestsAnywhere", // changed in 1.9
- "Default | UpdateKey": "Nexus:518",
- "~1.9-beta | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Choose Baby Gender": {
- "FormerIDs": "{EntryDll: 'ChooseBabyGender.dll'}",
- "Default | UpdateKey": "Nexus:590",
- "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "CJB Automation": {
- "ID": "CJBAutomation",
- "Default | UpdateKey": "Nexus:211",
- "~1.4 | Status": "AssumeBroken", // broke in SDV 1.2
- "~1.4 | AlternativeUrl": "http://www.nexusmods.com/stardewvalley/mods/1063"
- },
-
- "CJB Cheats Menu": {
- "ID": "CJBok.CheatsMenu",
- "FormerIDs": "CJBCheatsMenu", // changed in 1.14
- "Default | UpdateKey": "Nexus:4",
- "~1.12 | Status": "AssumeBroken" // broke in SDV 1.1
- },
-
- "CJB Item Spawner": {
- "ID": "CJBok.ItemSpawner",
- "FormerIDs": "CJBItemSpawner", // changed in 1.7
- "Default | UpdateKey": "Nexus:93",
- "~1.5 | Status": "AssumeBroken" // broke in SDV 1.1
- },
-
- "CJB Show Item Sell Price": {
- "ID": "CJBok.ShowItemSellPrice",
- "FormerIDs": "CJBShowItemSellPrice", // changed in 1.7
- "Default | UpdateKey": "Nexus:5",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Clean Farm": {
- "ID": "tstaples.CleanFarm",
- "Default | UpdateKey": "Nexus:794"
- },
-
- "Climates of Ferngill": {
- "ID": "KoihimeNakamura.ClimatesOfFerngill",
- "Default | UpdateKey": "Nexus:604",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Coal Regen": {
- "ID": "Blucifer.CoalRegen",
- "Default | UpdateKey": "Nexus:1664"
- },
-
- "Cold Weather Haley": {
- "ID": "LordXamon.ColdWeatherHaleyPRO",
- "Default | UpdateKey": "Nexus:1169",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Colored Chests": {
- "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1."
- },
-
- "Combat with Farm Implements": {
- "ID": "SPDFarmingImplementsInCombat",
- "Default | UpdateKey": "Nexus:313",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Community Bundle Item Tooltip": {
- "ID": "musbah.bundleTooltip",
- "Default | UpdateKey": "Nexus:1329"
- },
-
- "Concentration on Farming": {
- "ID": "punyo.ConcentrationOnFarming",
- "Default | UpdateKey": "Nexus:1445"
- },
-
- "Configurable Machines": {
- "ID": "21da6619-dc03-4660-9794-8e5b498f5b97",
- "MapLocalVersions": { "1.2-beta": "1.2" },
- "Default | UpdateKey": "Nexus:280"
- },
-
- "Configurable Shipping Dates": {
- "ID": "ConfigurableShippingDates",
- "Default | UpdateKey": "Nexus:675",
- "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Cooking Skill": {
- "ID": "spacechase0.CookingSkill",
- "FormerIDs": "CookingSkill", // changed in 1.0.4–6
- "Default | UpdateKey": "Nexus:522",
- "~1.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "CrabNet": {
- "ID": "jwdred.CrabNet",
- "FormerIDs": "{EntryDll: 'CrabNet.dll'}", // changed in 1.0.5
- "Default | UpdateKey": "Nexus:584",
- "~1.0.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Crafting Counter": {
- "ID": "lolpcgaming.CraftingCounter",
- "Default | UpdateKey": "Nexus:1585"
- },
-
- "Current Location": {
- "ID": "CurrentLocation102120161203",
- "Default | UpdateKey": "Nexus:638"
- },
-
- "Custom Asset Modifier": {
- "ID": "Omegasis.CustomAssetModifier",
- "Default | UpdateKey": "1836"
- },
-
- "Custom Critters": {
- "ID": "spacechase0.CustomCritters",
- "Default | UpdateKey": "Nexus:1255"
- },
-
- "Custom Crops": {
- "ID": "spacechase0.CustomCrops",
- "Default | UpdateKey": "Nexus:1592"
- },
-
- "Custom Element Handler": {
- "ID": "Platonymous.CustomElementHandler",
- "Default | UpdateKey": "Nexus:1068" // added in 1.3.1
- },
-
- "Custom Farming Redux": {
- "ID": "Platonymous.CustomFarming",
- "Default | UpdateKey": "Nexus:991" // added in 0.6.1
- },
-
- "Custom Farming Automate Bridge": {
- "ID": "Platonymous.CFAutomate",
- "~1.0.1 | Status": "AssumeBroken", // no longer compatible with Automate
- "~1.0.1 | AlternativeUrl": "https://www.nexusmods.com/stardewvalley/mods/991"
- },
-
- "Custom Farm Types": {
- "ID": "spacechase0.CustomFarmTypes",
- "Default | UpdateKey": "Nexus:1140"
- },
-
- "Custom Furniture": {
- "ID": "Platonymous.CustomFurniture",
- "Default | UpdateKey": "Nexus:1254" // added in 0.4.1
- },
-
- "Customize Exterior": {
- "ID": "spacechase0.CustomizeExterior",
- "FormerIDs": "CustomizeExterior", // changed in 1.0.3
- "Default | UpdateKey": "Nexus:1099",
- "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Customizable Cart Redux": {
- "ID": "KoihimeNakamura.CCR",
- "MapLocalVersions": { "1.1-20170917": "1.1" },
- "Default | UpdateKey": "Nexus:1402"
- },
-
- "Customizable Traveling Cart Days": {
- "ID": "TravelingCartYyeahdude",
- "Default | UpdateKey": "Nexus:567",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Custom Linens": {
- "ID": "Mevima.CustomLinens",
- "MapRemoteVersions": { "1.1": "1.0" }, // manifest not updated
- "Default | UpdateKey": "Nexus:1027"
- },
-
- "Custom NPC": {
- "ID": "Platonymous.CustomNPC",
- "Default | UpdateKey": "Nexus:1607"
- },
-
- "Custom Shops Redux": {
- "ID": "Omegasis.CustomShopReduxGui",
- "Default | UpdateKey": "Nexus:1378" // added in 1.4.1
- },
-
- "Custom TV": {
- "ID": "Platonymous.CustomTV",
- "Default | UpdateKey": "Nexus:1139" // added in 1.0.6
- },
-
- "Daily Luck Message": {
- "ID": "Schematix.DailyLuckMessage",
- "Default | UpdateKey": "Nexus:1327"
- },
-
- "Daily News": {
- "ID": "bashNinja.DailyNews",
- "Default | UpdateKey": "Nexus:1141",
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Daily Quest Anywhere": {
- "ID": "Omegasis.DailyQuestAnywhere",
- "FormerIDs": "DailyQuest", // changed in 1.4
- "Default | UpdateKey": "Nexus:513" // added in 1.4.1
- },
-
- "Debug Mode": {
- "ID": "Pathoschild.DebugMode",
- "FormerIDs": "Pathoschild.Stardew.DebugMode", // changed in 1.4
- "Default | UpdateKey": "Nexus:679"
- },
-
- "Did You Water Your Crops?": {
- "ID": "Nishtra.DidYouWaterYourCrops",
- "Default | UpdateKey": "Nexus:1583"
- },
-
- "Dynamic Checklist": {
- "ID": "gunnargolf.DynamicChecklist",
- "Default | UpdateKey": "Nexus:1145", // added in 1.0.1-pathoschild-update
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Dynamic Horses": {
- "ID": "Bpendragon-DynamicHorses",
- "MapRemoteVersions": { "1.2": "1.1-release" }, // manifest not updated
- "Default | UpdateKey": "Nexus:874"
- },
-
- "Dynamic Machines": {
- "ID": "DynamicMachines",
- "MapLocalVersions": { "1.1": "1.1.1" },
- "Default | UpdateKey": "Nexus:374",
- "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Dynamic NPC Sprites": {
- "ID": "BashNinja.DynamicNPCSprites",
- "Default | UpdateKey": "Nexus:1183"
- },
-
- "Easier Farming": {
- "ID": "cautiouswafffle.EasierFarming",
- "Default | UpdateKey": "Nexus:1426"
- },
-
- "Empty Hands": {
- "ID": "QuicksilverFox.EmptyHands",
- "Default | UpdateKey": "Nexus:1176", // added in 1.0.1-pathoschild-update
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Enemy Health Bars": {
- "ID": "Speeder.HealthBars",
- "FormerIDs": "SPDHealthBar", // changed in 1.7.1-pathoschild-update
- "Default | UpdateKey": "Nexus:193",
- "~1.7 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Entoarox Framework": {
- "ID": "Entoarox.EntoaroxFramework",
- "FormerIDs": "eacdb74b-4080-4452-b16b-93773cda5cf9", // changed in ???
- "~2.0.6 | UpdateKey": "Chucklefish:4228", // only enable update checks up to 2.0.6 by request (has its own update-check feature)
- "~2.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.5 (error reflecting into SMAPI internals)
- },
-
- "Expanded Fridge": {
- "ID": "Uwazouri.ExpandedFridge",
- "Default | UpdateKey": "Nexus:1191"
- },
-
- "Experience Bars": {
- "ID": "spacechase0.ExperienceBars",
- "FormerIDs": "ExperienceBars", // changed in 1.0.2
- "Default | UpdateKey": "Nexus:509"
- },
-
- "Extended Bus System": {
- "ID": "ExtendedBusSystem",
- "Default | UpdateKey": "Chucklefish:4373"
- },
-
- "Extended Fridge": {
- "ID": "Crystalmir.ExtendedFridge",
- "FormerIDs": "Mystra007ExtendedFridge", // changed in 1.0.1
- "Default | UpdateKey": "Nexus:485",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Extended Greenhouse": {
- "ID": "ExtendedGreenhouse",
- "Default | UpdateKey": "Chucklefish:4303",
- "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Extended Minecart": {
- "ID": "Entoarox.ExtendedMinecart",
- "FormerIDs": "{ID:'EntoaroxFurnitureAnywhere', Name:'Extended Minecart'}", // changed in 1.6.1
- "~1.7.1 | UpdateKey": "Chucklefish:4359" // only enable update checks up to 1.7.1 by request (has its own update-check feature)
- },
-
- "Extended Reach": {
- "ID": "spacechase0.ExtendedReach",
- "Default | UpdateKey": "Nexus:1493"
- },
-
- "Fall 28 Snow Day": {
- "ID": "Omegasis.Fall28SnowDay",
- "FormerIDs": "{ID:'7ad4f6f7-c3de-4729-a40f-7a11d2b2a358', Name:'Fall28 Snow Day'}", // changed in 1.4; disambiguate from other mods by Alpha_Omegasis
- "Default | UpdateKey": "Nexus:486", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Farm Automation: Barn Door Automation": {
- "FormerIDs": "{EntryDll: 'FarmAutomation.BarnDoorAutomation.dll'}",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Farm Automation: Item Collector": {
- "FormerIDs": "{EntryDll: 'FarmAutomation.ItemCollector.dll'}",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Farm Automation Unofficial: Item Collector": {
- "ID": "Maddy99.FarmAutomation.ItemCollector",
- "~0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Farm Expansion": {
- "ID": "Advize.FarmExpansion",
- "FormerIDs": "3888bdfd-73f6-4776-8bb7-8ad45aea1915 | AdvizeFarmExpansionMod-2-0 | AdvizeFarmExpansionMod-2-0-5", // changed in 2.0, 2.0.5, and 3.0
- "Default | UpdateKey": "Nexus:130",
- "~2.0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Farm Resource Generator": {
- "FormerIDs": "{EntryDll: 'FarmResourceGenerator.dll'}",
- "Default | UpdateKey": "Nexus:647",
- "~1.0.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Fast Animations": {
- "ID": "Pathoschild.FastAnimations",
- "Default | UpdateKey": "Nexus:1089"
- },
-
- "Faster Grass": {
- "ID": "IceGladiador.FasterGrass",
- "Default | UpdateKey": "Nexus:1772"
- },
-
- "Faster Paths": {
- "ID": "Entoarox.FasterPaths",
- "FormerIDs": "{ID:'821ce8f6-e629-41ad-9fde-03b54f68b0b6', Name:'Faster Paths'} | 615f85f8-5c89-44ee-aecc-c328f172e413", // changed in 1.2 and 1.3; disambiguate from Shop Expander
- "~1.3.3 | UpdateKey": "Chucklefish:3641" // only enable update checks up to 1.3.3 by request (has its own update-check feature)
- },
-
- "Faster Run": {
- "ID": "KathrynHazuka.FasterRun",
- "FormerIDs": "{EntryDll: 'FasterRun.dll'}", // changed in 1.1.1-pathoschild-update
- "Default | UpdateKey": "Nexus:733", // added in 1.1.1-pathoschild-update
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Fishing Adjust": {
- "ID": "shuaiz.FishingAdjustMod",
- "Default | UpdateKey": "Nexus:1350"
- },
-
- "Fishing Tuner Redux": {
- "ID": "HammurabiFishingTunerRedux",
- "Default | UpdateKey": "Chucklefish:4578"
- },
-
- "Fixed Secret Woods Debris": {
- "ID": "f4iTh.WoodsDebrisFix",
- "Default | UpdateKey": "Nexus:1941"
- },
-
- "FlorenceMod": {
- "FormerIDs": "{EntryDll: 'FlorenceMod.dll'}",
- "MapLocalVersions": { "1.0.1": "1.1" },
- "Default | UpdateKey": "Nexus:591",
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Flower Color Picker": {
- "ID": "spacechase0.FlowerColorPicker",
- "Default | UpdateKey": "Nexus:1229"
- },
-
- "Forage at the Farm": {
- "ID": "Nishtra.ForageAtTheFarm",
- "FormerIDs": "ForageAtTheFarm", // changed in <=1.6
- "Default | UpdateKey": "Nexus:673",
- "~1.5.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Furniture Anywhere": {
- "ID": "Entoarox.FurnitureAnywhere",
- "FormerIDs": "{ID:'EntoaroxFurnitureAnywhere', Name:'Furniture Anywhere'}", // changed in 1.1; disambiguate from Extended Minecart
- "~1.1.5 | UpdateKey": "Chucklefish:4324" // only enable update checks up to 1.1.5 by request (has its own update-check feature)
- },
-
- "Game Reminder": {
- "ID": "mmanlapat.GameReminder",
- "Default | UpdateKey": "Nexus:1153"
- },
-
- "Gate Opener": {
- "ID": "mralbobo.GateOpener",
- "FormerIDs": "{EntryDll: 'GateOpener.dll'}", // changed in 1.1
- "Default | UpdateKey": "GitHub:mralbobo/stardew-gate-opener",
- "~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "GenericShopExtender": {
- "ID": "GenericShopExtender",
- "Default | UpdateKey": "Nexus:814", // added in 0.1.3
- "~0.1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Geode Info Menu": {
- "ID": "cat.geodeinfomenu",
- "Default | UpdateKey": "Nexus:1448"
- },
-
- "Get Dressed": {
- "ID": "Advize.GetDressed",
- "FormerIDs": "{EntryDll: 'GetDressed.dll'}", // changed in 3.3
- "Default | UpdateKey": "Nexus:331",
- "~3.3 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Giant Crop Ring": {
- "ID": "cat.giantcropring",
- "Default | UpdateKey": "Nexus:1182"
- },
-
- "Gift Taste Helper": {
- "ID": "tstaples.GiftTasteHelper",
- "FormerIDs": "8008db57-fa67-4730-978e-34b37ef191d6", // changed in 2.5
- "Default | UpdateKey": "Nexus:229",
- "~2.3.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Grandfather's Gift": {
- "ID": "ShadowDragon.GrandfathersGift",
- "Default | UpdateKey": "Nexus:985"
- },
-
- "Happy Animals": {
- "ID": "HappyAnimals",
- "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Happy Birthday (Omegasis)": {
- "ID": "Omegasis.HappyBirthday",
- "FormerIDs": "{ID:'HappyBirthday', Author:'Alpha_Omegasis'}", // changed in 1.4; disambiguate from Oxyligen's fork
- "Default | UpdateKey": "Nexus:520", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Happy Birthday (Oxyligen fork)": {
- "FormerIDs": "{ID:'HappyBirthday', Author:'Alpha_Omegasis/Oxyligen'}", // disambiguate from Oxyligen's fork
- "Default | UpdateKey": "Nexus:1064" // missing key reported: https://www.nexusmods.com/stardewvalley/mods/1064?tab=bugs
- },
-
- "Hardcore Mines": {
- "ID": "kibbe.hardcore_mines",
- "Default | UpdateKey": "Nexus:1674"
- },
-
- "Harp of Yoba Redux": {
- "ID": "Platonymous.HarpOfYobaRedux",
- "Default | UpdateKey": "Nexus:914" // added in 2.0.3
- },
-
- "Harvest Moon Witch Princess": {
- "ID": "Sasara.WitchPrincess",
- "Default | UpdateKey": "Nexus:1157"
- },
-
- "Harvest With Scythe": {
- "ID": "965169fd-e1ed-47d0-9f12-b104535fb4bc",
- "Default | UpdateKey": "Nexus:236",
- "~1.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Horse Whistle (icepuente)": {
- "ID": "icepuente.HorseWhistle",
- "Default | UpdateKey": "Nexus:1131"
- },
-
- "Hunger (Yyeadude)": {
- "ID": "HungerYyeadude",
- "Default | UpdateKey": "Nexus:613"
- },
-
- "Hunger for Food (Tigerle)": {
- "ID": "HungerForFoodByTigerle",
- "Default | UpdateKey": "Nexus:810",
- "~0.1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Hunger Mod (skn)": {
- "ID": "skn.HungerMod",
- "MapRemoteVersions": { "1.2.1": "1.0" }, // manifest not updated
- "Default | UpdateKey": "Nexus:1127"
- },
-
- "Idle Pause": {
- "ID": "Veleek.IdlePause",
- "MapRemoteVersions": { "1.2": "1.1" }, // manifest not updated
- "Default | UpdateKey": "Nexus:1092"
- },
-
- "Improved Quality of Life": {
- "ID": "Demiacle.ImprovedQualityOfLife",
- "Default | UpdateKey": "Nexus:1025",
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Instant Geode": {
- "ID": "InstantGeode",
- "~1.12 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Instant Grow Trees": {
- "ID": "cantorsdust.InstantGrowTrees",
- "FormerIDs": "dc50c58b-c7d8-4e60-86cc-e27b5d95ee59 | community.InstantGrowTrees", // changed in 1.2 and 1.3.1
- "Default | UpdateKey": "Nexus:173"
- },
-
- "Interaction Helper": {
- "ID": "HammurabiInteractionHelper",
- "Default | UpdateKey": "Chucklefish:4640", // added in 1.0.4-pathoschild-update
- "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Item Auto Stacker": {
- "ID": "cat.autostacker",
- "MapRemoteVersions": { "1.0.1": "1.0" }, // manifest not updated
- "Default | UpdateKey": "Nexus:1184"
- },
-
- "Jiggly Junimo Bundles": {
- "ID": "Greger.JigglyJunimoBundles",
- "FormerIDs": "{EntryDll: 'JJB.dll'}", // changed in 1.1.2-pathoschild-update
- "Default | UpdateKey": "GitHub:gr3ger/Stardew_JJB" // added in 1.0.4-pathoschild-update
- },
-
- "Json Assets": {
- "ID": "spacechase0.JsonAssets",
- "Default | UpdateKey": "Nexus:1720"
- },
-
- "Junimo Farm": {
- "ID": "Platonymous.JunimoFarm",
- "MapRemoteVersions": { "1.1.2": "1.1.1" }, // manifest not updated
- "Default | UpdateKey": "Nexus:984" // added in 1.1.3
- },
-
- "Less Strict Over-Exertion (AntiExhaustion)": {
- "ID": "BALANCEMOD_AntiExhaustion",
- "MapLocalVersions": { "0.0": "1.1" },
- "Default | UpdateKey": "Nexus:637",
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Level Extender": {
- "ID": "Devin Lematty.Level Extender",
- "MapRemoteVersions": { "1.1": "1.0" }, // manifest not updated
- "Default | UpdateKey": "Nexus:1471"
- },
-
- "Level Up Notifications": {
- "ID": "Level Up Notifications",
- "MapRemoteVersions": { "0.0.1a": "0.0.1" },
- "Default | UpdateKey": "Nexus:855"
- },
-
- "Location and Music Logging": {
- "ID": "Brandy Lover.LMlog",
- "Default | UpdateKey": "Nexus:1366"
- },
-
- "Longevity": {
- "ID": "RTGOAT.Longevity",
- "MapRemoteVersions": { "1.6.8h": "1.6.8" },
- "Default | UpdateKey": "Nexus:649"
- },
-
- "Lookup Anything": {
- "ID": "Pathoschild.LookupAnything",
- "FormerIDs": "LookupAnything", // changed in 1.10.1
- "Default | UpdateKey": "Nexus:541",
- "~1.10.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Love Bubbles": {
- "ID": "LoveBubbles",
- "Default | UpdateKey": "Nexus:1318"
- },
-
- "Loved Labels": {
- "ID": "Advize.LovedLabels",
- "FormerIDs": "{EntryDll: 'LovedLabels.dll'}", // changed in 2.1
- "Default | UpdateKey": "Nexus:279",
- "~2.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Luck Skill": {
- "ID": "spacechase0.LuckSkill",
- "FormerIDs": "LuckSkill", // changed in 0.1.4
- "Default | UpdateKey": "Nexus:521",
- "~0.1.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Mail Framework": {
- "ID": "DIGUS.MailFrameworkMod",
- "Default | UpdateKey": "Nexus:1536"
- },
-
- "MailOrderPigs": {
- "ID": "jwdred.MailOrderPigs",
- "FormerIDs": "{EntryDll: 'MailOrderPigs.dll'}", // changed in 1.0.2
- "Default | UpdateKey": "Nexus:632",
- "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
+ "DumpMetadata": false,
- "Makeshift Multiplayer": {
- "ID": "spacechase0.StardewValleyMP",
- "FormerIDs": "StardewValleyMP", // changed in 0.3
- "Default | UpdateKey": "Nexus:501",
- "~0.3.6 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Map Image Exporter": {
- "ID": "spacechase0.MapImageExporter",
- "FormerIDs": "MapImageExporter", // changed in 1.0.2
- "Default | UpdateKey": "Nexus:1073"
- },
-
- "Message Box [API]? (ChatMod)": {
- "ID": "Kithio:ChatMod",
- "Default | UpdateKey": "Chucklefish:4296",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Mining at the Farm": {
- "ID": "Nishtra.MiningAtTheFarm",
- "FormerIDs": "MiningAtTheFarm", // changed in <=1.7
- "Default | UpdateKey": "Nexus:674"
- },
-
- "Mining With Explosives": {
- "ID": "Nishtra.MiningWithExplosives",
- "FormerIDs": "MiningWithExplosives", // changed in 1.1
- "Default | UpdateKey": "Nexus:770"
- },
-
- "Modder Serialization Utility": {
- "ID": "SerializerUtils-0-1",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "it's no longer maintained or used."
- },
-
- "Monster Level Tip": {
- "ID": "WhiteMind.MonsterLT",
- "Default | UpdateKey": "Nexus:1896"
- },
-
- "More Animals": {
- "ID": "Entoarox.MoreAnimals",
- "FormerIDs": "821ce8f6-e629-41ad-9fde-03b54f68b0b6MOREPETS | Entoarox.MorePets", // changed in 1.3 and 2.0
- "~2.0.2 | UpdateKey": "Chucklefish:4288", // only enable update checks up to 2.0.2 by request (has its own update-check feature)
- "~1.3.2 | Status": "AssumeBroken" // overhauled for SMAPI 1.11+ compatibility
- },
-
- "More Artifact Spots": {
- "ID": "451",
- "Default | UpdateKey": "Nexus:451",
- "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "More Map Layers": {
- "ID": "Platonymous.MoreMapLayers",
- "Default | UpdateKey": "Nexus:1134" // added in 1.1.1
- },
-
- "More Rain": {
- "ID": "Omegasis.MoreRain",
- "FormerIDs": "{ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'More_Rain'}", // changed in 1.5; disambiguate from other mods by Alpha_Omegasis
- "Default | UpdateKey": "Nexus:441", // added in 1.5.1
- "~1.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "More Weapons": {
- "ID": "Joco80.MoreWeapons",
- "Default | UpdateKey": "Nexus:1168"
- },
-
- "Move Faster": {
- "ID": "shuaiz.MoveFasterMod",
- "Default | UpdateKey": "Nexus:1351"
- },
-
- "Multiple Sprites and Portraits On Rotation (File Loading)": {
- "ID": "FileLoading",
- "MapLocalVersions": { "1.1": "1.12" },
- "Default | UpdateKey": "Nexus:1094",
- "~1.12 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Museum Rearranger": {
- "ID": "Omegasis.MuseumRearranger",
- "FormerIDs": "{ID:'7ad4f6f7-c3de-4729-a40f-7a11d2b2a358', Name:'Museum Rearranger'}", // changed in 1.4; disambiguate from other mods by Alpha_Omegasis
- "Default | UpdateKey": "Nexus:428", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Mushroom Level Tip": {
- "ID": "WhiteMind.MLT",
- "Default | UpdateKey": "Nexus:1894"
- },
-
- "New Machines": {
- "ID": "F70D4FAB-0AB2-4B78-9F1B-AF2CA2236A59",
- "Default | UpdateKey": "Chucklefish:3683",
- "~4.2.1343 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Night Owl": {
- "ID": "Omegasis.NightOwl",
- "FormerIDs": "{ID:'SaveAnywhere', Name:'Stardew_NightOwl'}", // changed in 1.4; disambiguate from Save Anywhere
- "MapLocalVersions": { "2.1": "1.3" }, // 1.3 had wrong version in manifest
- "Default | UpdateKey": "Nexus:433", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "No Crows": {
- "ID": "cat.nocrows",
- "Default | UpdateKey": "Nexus:1682"
- },
-
- "No Kids Ever": {
- "ID": "Hangy.NoKidsEver",
- "Default | UpdateKey": "Nexus:1464"
- },
-
- "No Debug Mode": {
- "ID": "NoDebugMode",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0."
- },
-
- "No Fence Decay": {
- "ID": "cat.nofencedecay",
- "Default | UpdateKey": "Nexus:1180"
- },
-
- "No More Pets": {
- "ID": "Omegasis.NoMorePets",
- "FormerIDs": "NoMorePets", // changed in 1.4
- "Default | UpdateKey": "Nexus:506" // added in 1.4.1
- },
-
- "No Rumble Horse": {
- "ID": "Xangria.NoRumbleHorse",
- "Default | UpdateKey": "Nexus:1779"
- },
-
- "No Soil Decay": {
- "ID": "289dee03-5f38-4d8e-8ffc-e440198e8610",
- "Default | UpdateKey": "Nexus:237",
- "~0.5 | Status": "AssumeBroken" // broke in SDV 1.2 and uses Assembly.GetExecutingAssembly().Location
- },
-
- "No Soil Decay Redux": {
- "ID": "Platonymous.NoSoilDecayRedux",
- "Default | UpdateKey": "Nexus:1084" // added in 1.1.9
- },
-
- "NPC Map Locations": {
- "ID": "NPCMapLocationsMod",
- "Default | UpdateKey": "Nexus:239",
- "1.42~1.43 | Status": "AssumeBroken",
- "1.42~1.43 | StatusReasonPhrase": "this version has an update check error which crashes the game."
- },
-
- "NPC Speak": {
- "FormerIDs": "{EntryDll: 'NpcEcho.dll'}",
- "Default | UpdateKey": "Nexus:694",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Object Time Left": {
- "ID": "spacechase0.ObjectTimeLeft",
- "Default | UpdateKey": "Nexus:1315"
- },
-
- "OmniFarm": {
- "ID": "PhthaloBlue.OmniFarm",
- "FormerIDs": "BlueMod_OmniFarm", // changed in 2.0.2-pathoschild-update
- "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_OmniFarm",
- "~2.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Out of Season Bonuses (Seasonal Items)": {
- "ID": "midoriarmstrong.seasonalitems",
- "Default | UpdateKey": "Nexus:1452"
- },
-
- "Part of the Community": {
- "ID": "SB_PotC",
- "Default | UpdateKey": "Nexus:923",
- "~1.0.8 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "PelicanFiber": {
- "ID": "jwdred.PelicanFiber",
- "FormerIDs": "{EntryDll: 'PelicanFiber.dll'}", // changed in 3.0.1
- "MapRemoteVersions": { "3.0.2": "3.0.1" }, // didn't change manifest version
- "Default | UpdateKey": "Nexus:631",
- "~3.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "PelicanTTS": {
- "ID": "Platonymous.PelicanTTS",
- "Default | UpdateKey": "Nexus:1079", // added in 1.6.1
- "~1.6 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Persia the Mermaid - Standalone Custom NPC": {
- "ID": "63b9f419-7449-42db-ab2e-440b4d05c073",
- "Default | UpdateKey": "Nexus:1419"
- },
-
- "Persistent Game Options": {
- "ID": "Xangria.PersistentGameOptions",
- "Default | UpdateKey": "Nexus:1778"
- },
-
- "Persival's BundleMod": {
- "FormerIDs": "{EntryDll: 'BundleMod.dll'}",
- "Default | UpdateKey": "Nexus:438",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.1
- },
-
- "Plant on Grass": {
- "ID": "Demiacle.PlantOnGrass",
- "Default | UpdateKey": "Nexus:1026"
- },
-
- "PyTK - Platonymous Toolkit": {
- "ID": "Platonymous.Toolkit",
- "Default | UpdateKey": "Nexus:1726"
- },
-
- "Point-and-Plant": {
- "ID": "jwdred.PointAndPlant",
- "FormerIDs": "{EntryDll: 'PointAndPlant.dll'}", // changed in 1.0.3
- "Default | UpdateKey": "Nexus:572",
- "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Pony Weight Loss Program": {
- "ID": "BadNetCode.PonyWeightLossProgram",
- "Default | UpdateKey": "Nexus:1232"
- },
-
- "Portraiture": {
- "ID": "Platonymous.Portraiture",
- "Default | UpdateKey": "Nexus:999" // added in 1.3.1
- },
-
- "Prairie King Made Easy": {
- "ID": "Mucchan.PrairieKingMadeEasy",
- "FormerIDs": "{EntryDll: 'PrairieKingMadeEasy.dll'}", // changed in 1.0.1
- "Default | UpdateKey": "Chucklefish:3594",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Purchasable Recipes": {
- "ID": "Paracosm.PurchasableRecipes",
- "Default | UpdateKey": "Nexus:1722"
- },
-
- "Quest Delay": {
- "ID": "BadNetCode.QuestDelay",
- "Default | UpdateKey": "Nexus:1239"
- },
-
- "Rain Randomizer": {
- "FormerIDs": "{EntryDll: 'RainRandomizer.dll'}",
- "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Recatch Legendary Fish": {
- "ID": "cantorsdust.RecatchLegendaryFish",
- "FormerIDs": "b3af8c31-48f0-43cf-8343-3eb08bcfa1f9 | community.RecatchLegendaryFish", // changed in 1.3 and 1.5.1
- "Default | UpdateKey": "Nexus:172"
- },
-
- "Regeneration": {
- "ID": "HammurabiRegeneration",
- "Default | UpdateKey": "Chucklefish:4584"
- },
-
- "Relationship Bar UI": {
- "ID": "RelationshipBar",
- "Default | UpdateKey": "Nexus:1009"
- },
-
- "RelationshipsEnhanced": {
- "ID": "relationshipsenhanced",
- "Default | UpdateKey": "Chucklefish:4435",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Relationship Status": {
- "ID": "relationshipstatus",
- "MapRemoteVersions": { "1.0.5": "1.0.4" }, // not updated in manifest
- "Default | UpdateKey": "Nexus:751",
- "~1.0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Rented Tools": {
- "ID": "JarvieK.RentedTools",
- "Default | UpdateKey": "Nexus:1307"
- },
-
- "Replanter": {
- "ID": "jwdred.Replanter",
- "FormerIDs": "{EntryDll: 'Replanter.dll'}", // changed in 1.0.5
- "Default | UpdateKey": "Nexus:589",
- "~1.0.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "ReRegeneration": {
- "ID": "lrsk_sdvm_rerg.0925160827",
- "MapLocalVersions": { "1.1.2-release": "1.1.2" },
- "Default | UpdateKey": "Chucklefish:4465"
- },
-
- "Reseed": {
- "ID": "Roc.Reseed",
- "Default | UpdateKey": "Nexus:887"
- },
-
- "Reusable Wallpapers and Floors (Wallpaper Retain)": {
- "ID": "dae1b553-2e39-43e7-8400-c7c5c836134b",
- "Default | UpdateKey": "Nexus:356",
- "~1.5 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Ring of Fire": {
- "ID": "Platonymous.RingOfFire",
- "Default | UpdateKey": "Nexus:1166" // added in 1.0.1
- },
-
- "Rise and Shine": {
- "ID": "Yoshify.RiseAndShine",
- "FormerIDs": "{EntryDll: 'RiseAndShine.dll'}", // changed in 1.1.1-whisk-update
- "Default | UpdateKey": "Nexus:3"
- },
-
- "Rope Bridge": {
- "ID": "RopeBridge",
- "Default | UpdateKey": "Nexus:824"
- },
-
- "Rotate Toolbar": {
- "ID": "Pathoschild.RotateToolbar",
- "Default | UpdateKey": "Nexus:1100"
- },
-
- "Rush Orders": {
- "ID": "spacechase0.RushOrders",
- "FormerIDs": "RushOrders", // changed in 1.1
- "Default | UpdateKey": "Nexus:605",
- "~1.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Save Anywhere": {
- "ID": "Omegasis.SaveAnywhere",
- "FormerIDs": "{ID:'SaveAnywhere', Name:'Save Anywhere'}", // changed in 2.5; disambiguate from Night Owl
- "Default | UpdateKey": "Nexus:444", // added in 2.6.1
- "~2.4 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Save Backup": {
- "ID": "Omegasis.SaveBackup",
- "FormerIDs": "{ID:'4be88c18-b6f3-49b0-ba96-f94b1a5be890', Name:'Stardew_Save_Backup'}", // changed in 1.3; disambiguate from other Alpha_Omegasis mods
- "Default | UpdateKey": "Nexus:435", // added in 1.3.1
- "~1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Scroll to Blank": {
- "ID": "caraxian.scroll.to.blank",
- "Default | UpdateKey": "Chucklefish:4405"
- },
-
- "Scythe Harvesting": {
- "ID": "mmanlapat.ScytheHarvesting",
- "FormerIDs": "ScytheHarvesting", // changed in 1.6
- "Default | UpdateKey": "Nexus:1106"
- },
-
- "SDV Twitch": {
- "ID": "MTD.SDVTwitch",
- "Default | UpdateKey": "Nexus:1760"
- },
-
- "Seasonal Immersion": {
- "ID": "Entoarox.SeasonalImmersion",
- "FormerIDs": "EntoaroxSeasonalHouse | EntoaroxSeasonalBuildings | EntoaroxSeasonalImmersion", // changed in 1.1, 1.6 or earlier, and 1.7
- "~1.11 | UpdateKey": "Chucklefish:4262", // only enable update checks up to 1.11 by request (has its own update-check feature)
- "~1.8.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Seed Bag": {
- "ID": "Platonymous.SeedBag",
- "Default | UpdateKey": "Nexus:1133" // added in 1.1.2
- },
-
- "Seed Catalogue": {
- "ID": "spacechase0.SeedCatalogue",
- "Default | UpdateKey": "Nexus:1640"
- },
-
- "Self Service": {
- "ID": "JarvieK.SelfService",
- "MapRemoteVersions": { "0.2.1": "0.2" }, // manifest not updated
- "Default | UpdateKey": "Nexus:1304"
- },
-
- "Send Items": {
- "ID": "Denifia.SendItems",
- "Default | UpdateKey": "Nexus:1087", // added in 1.0.3 (2017-10-04)
- "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Shed Notifications (BuildingsNotifications)": {
- "ID": "TheCroak.BuildingsNotifications",
- "Default | UpdateKey": "Nexus:620",
- "~0.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Shenandoah Project": {
- "ID": "Nishtra.ShenandoahProject",
- "FormerIDs": "Shenandoah Project", // changed in 1.2
- "MapRemoteVersions": { "1.1.1": "1.1" }, // not updated in manifest
- "Default | UpdateKey": "Nexus:756",
- "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Ship Anywhere": {
- "ID": "spacechase0.ShipAnywhere",
- "Default | UpdateKey": "Nexus:1379"
- },
-
- "Shipment Tracker": {
- "ID": "7e474181-e1a0-40f9-9c11-d08a3dcefaf3",
- "Default | UpdateKey": "Nexus:321",
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Shop Expander": {
- "ID": "Entoarox.ShopExpander",
- "FormerIDs": "{ID:'821ce8f6-e629-41ad-9fde-03b54f68b0b6', Name:'Shop Expander'} | EntoaroxShopExpander", // changed in 1.5 and 1.5.2; disambiguate from Faster Paths
- "MapRemoteVersions": { "1.6.0b": "1.6.0" },
- "~1.6 | UpdateKey": "Chucklefish:4381", // only enable update checks up to 1.6 by request (has its own update-check feature)
- "~1.5.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Showcase Mod": {
- "ID": "Igorious.Showcase",
- "MapLocalVersions": { "0.9-500": "0.9" },
- "Default | UpdateKey": "Chucklefish:4487",
- "~0.9 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Shroom Spotter": {
- "ID": "TehPers.ShroomSpotter",
- "Default | UpdateKey": "Nexus:908"
- },
-
- "Simple Crop Label": {
- "ID": "SimpleCropLabel",
- "Default | UpdateKey": "Nexus:314"
- },
-
- "Simple Sound Manager": {
- "ID": "Omegasis.SimpleSoundManager",
- "Default | UpdateKey": "Nexus:1410", // added in 1.0.1
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Simple Sprinklers": {
- "ID": "tZed.SimpleSprinkler",
- "FormerIDs": "{EntryDll: 'SimpleSprinkler.dll'}", // changed in 1.5
- "Default | UpdateKey": "Nexus:76",
- "~1.4 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Siv's Marriage Mod": {
- "ID": "6266959802",
- "MapLocalVersions": { "0.0": "1.4" },
- "Default | UpdateKey": "Nexus:366",
- "~1.2.2 | Status": "AssumeBroken" // broke in SMAPI 1.9 (has multiple Mod instances)
- },
-
- "Skill Prestige": {
- "ID": "alphablackwolf.skillPrestige",
- "FormerIDs": "6b843e60-c8fc-4a25-a67b-4a38ac8dcf9b", // changed circa 1.2.3
- "Default | UpdateKey": "Nexus:569",
- "~1.0.9 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Skill Prestige: Cooking Adapter": {
- "ID": "Alphablackwolf.CookingSkillPrestigeAdapter",
- "FormerIDs": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63", // changed circa 1.1
- "MapRemoteVersions": { "1.2.3": "1.1" }, // manifest not updated
- "Default | UpdateKey": "Nexus:569",
- "~1.0.9 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Skip Intro": {
- "ID": "Pathoschild.SkipIntro",
- "FormerIDs": "SkipIntro", // changed in 1.4
- "Default | UpdateKey": "Nexus:533"
- },
-
- "Skull Cavern Elevator": {
- "ID": "SkullCavernElevator",
- "Default | UpdateKey": "Nexus:963"
- },
-
- "Skull Cave Saver": {
- "ID": "cantorsdust.SkullCaveSaver",
- "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2
- "Default | UpdateKey": "Nexus:175"
- },
-
- "Sleepy Eye": {
- "ID": "spacechase0.SleepyEye",
- "Default | UpdateKey": "Nexus:1152"
- },
-
- "Slower Fence Decay": {
- "ID": "Speeder.SlowerFenceDecay",
- "FormerIDs": "SPDSlowFenceDecay", // changed in 0.5.2-pathoschild-update
- "Default | UpdateKey": "Nexus:252",
- "~0.5.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Smart Mod": {
- "ID": "KuroBear.SmartMod",
- "~2.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Solar Eclipse Event": {
- "ID": "KoihimeNakamura.SolarEclipseEvent",
- "Default | UpdateKey": "Nexus:897",
- "MapLocalVersions": { "1.3-20170917": "1.3" },
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "SpaceCore": {
- "ID": "spacechase0.SpaceCore",
- "Default | UpdateKey": "Nexus:1348"
- },
-
- "Speedster": {
- "ID": "Platonymous.Speedster",
- "Default | UpdateKey": "Nexus:1102" // added in 1.3.1
- },
-
- "Sprinkler Range": {
- "ID": "cat.sprinklerrange",
- "Default | UpdateKey": "Nexus:1179"
- },
-
- "Sprinkles": {
- "ID": "Platonymous.Sprinkles",
- "Default | UpdateKey": "Chucklefish:4592",
- "~1.1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Sprint and Dash": {
- "ID": "SPDSprintAndDash",
- "Default | UpdateKey": "Chucklefish:3531",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Sprint and Dash Redux": {
- "ID": "littleraskol.SprintAndDashRedux",
- "FormerIDs": "lrsk_sdvm_sndr.0921161059", // changed in 1.3
- "Default | UpdateKey": "Chucklefish:4201"
- },
-
- "Sprinting Mod": {
- "FormerIDs": "{EntryDll: 'SprintingMod.dll'}",
- "MapLocalVersions": { "1.0": "2.1" }, // not updated in manifest
- "Default | UpdateKey": "GitHub:oliverpl/SprintingMod",
- "~2.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "StackSplitX": {
- "ID": "tstaples.StackSplitX",
- "FormerIDs": "{EntryDll: 'StackSplitX.dll'}", // changed circa 1.3.1
- "Default | UpdateKey": "Nexus:798",
- "~1.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "StaminaRegen": {
- "FormerIDs": "{EntryDll: 'StaminaRegen.dll'}",
- "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Stardew Config Menu": {
- "ID": "Juice805.StardewConfigMenu",
- "Default | UpdateKey": "Nexus:1312"
- },
-
- "Stardew Content Compatibility Layer (SCCL)": {
- "ID": "SCCL",
- "Default | UpdateKey": "Nexus:889",
- "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Stardew Editor Game Integration": {
- "ID": "spacechase0.StardewEditor.GameIntegration",
- "Default | UpdateKey": "Nexus:1298"
- },
-
- "Stardew Notification": {
- "ID": "stardewnotification",
- "Default | UpdateKey": "GitHub:monopandora/StardewNotification",
- "~1.7 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Stardew Symphony": {
- "ID": "Omegasis.StardewSymphony",
- "FormerIDs": "{ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'Stardew_Symphony'}", // changed in 1.4; disambiguate other mods by Alpha_Omegasis
- "Default | UpdateKey": "Nexus:425", // added in 1.4.1
- "~1.3 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "StarDustCore": {
- "ID": "StarDustCore",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "it was only used by earlier versions of Save Anywhere, and is no longer used or maintained."
- },
-
- "Starting Money": {
- "ID": "mmanlapat.StartingMoney",
- "FormerIDs": "StartingMoney", // changed in 1.1
- "Default | UpdateKey": "Nexus:1138"
- },
-
- "StashItemsToChest": {
- "ID": "BlueMod_StashItemsToChest",
- "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_StashItemsToChest",
- "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Stephan's Lots of Crops": {
- "ID": "stephansstardewcrops",
- "MapRemoteVersions": { "1.41": "1.1" }, // manifest not updated
- "Default | UpdateKey": "Chucklefish:4314"
- },
-
- "Stone Bridge Over Pond (PondWithBridge)": {
- "FormerIDs": "{EntryDll: 'PondWithBridge.dll'}",
- "MapLocalVersions": { "0.0": "1.0" },
- "Default | UpdateKey": "Nexus:316",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Stumps to Hardwood Stumps": {
- "ID": "StumpsToHardwoodStumps",
- "Default | UpdateKey": "Nexus:691"
- },
-
- "Super Greenhouse Warp Modifier": {
- "ID": "SuperGreenhouse",
- "Default | UpdateKey": "Chucklefish:4334",
- "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Swim Almost Anywhere / Swim Suit": {
- "ID": "Platonymous.SwimSuit",
- "Default | UpdateKey": "Nexus:1215" // added in 0.5.1
- },
-
- "Tainted Cellar": {
- "ID": "TaintedCellar",
- "FormerIDs": "{EntryDll: 'TaintedCellar.dll'}", // changed in 1.1
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.1 or 1.11
- },
-
- "Tapper Ready": {
- "ID": "skunkkk.TapperReady",
- "Default | UpdateKey": "Nexus:1219"
- },
-
- "Teh's Fishing Overhaul": {
- "ID": "TehPers.FishingOverhaul",
- "Default | UpdateKey": "Nexus:866"
- },
-
- "Teleporter": {
- "ID": "Teleporter",
- "Default | UpdateKey": "Chucklefish:4374",
- "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "The Long Night": {
- "ID": "Pathoschild.TheLongNight",
- "Default | UpdateKey": "Nexus:1369"
- },
-
- "Three-heart Dance Partner": {
- "ID": "ThreeHeartDancePartner",
- "Default | UpdateKey": "Nexus:500",
- "~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "TimeFreeze": {
- "ID": "Omegasis.TimeFreeze",
- "FormerIDs": "4108e859-333c-4fec-a1a7-d2e18c1019fe", // changed in 1.2
- "Default | UpdateKey": "Nexus:973" // added in 1.2.1
- },
-
- "Time Reminder": {
- "ID": "KoihimeNakamura.TimeReminder",
- "MapLocalVersions": { "1.0-20170314": "1.0.2" },
- "Default | UpdateKey": "Nexus:1000"
- },
-
- "TimeSpeed": {
- "ID": "cantorsdust.TimeSpeed",
- "FormerIDs": "{EntryDll: 'TimeSpeed.dll'} | {ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'TimeSpeed'} | {ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'TimeSpeed Mod (unofficial)'} | community.TimeSpeed", // changed in 2.0.3, 2.1, and 2.3.3; disambiguate other mods by Alpha_Omegasis
- "Default | UpdateKey": "Nexus:169",
- "~2.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "To Do List": {
- "ID": "eleanor.todolist",
- "Default | UpdateKey": "Nexus:1630"
- },
-
- "Tool Charging": {
- "ID": "mralbobo.ToolCharging",
- "Default | UpdateKey": "GitHub:mralbobo/stardew-tool-charging"
- },
-
- "TractorMod": {
- "ID": "Pathoschild.TractorMod",
- "FormerIDs": "BlueMod_TractorMod | PhthaloBlue.TractorMod | community.TractorMod", // changed in 3.2, 4.0 beta, and 4.0
- "Default | UpdateKey": "Nexus:1401"
- },
-
- "TrainerMod": {
- "ID": "SMAPI.TrainerMod",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "replaced by ConsoleCommands, which is added by the SMAPI installer."
- },
-
- "Tree Transplant": {
- "ID": "TreeTransplant",
- "Default | UpdateKey": "Nexus:1342"
- },
-
- "UI Info Suite": {
- "ID": "Cdaragorn.UiInfoSuite",
- "Default | UpdateKey": "Nexus:1150"
- },
-
- "UiModSuite": {
- "ID": "Demiacle.UiModSuite",
- "MapLocalVersions": { "0.5": "1.0" }, // not updated in manifest
- "Default | UpdateKey": "Nexus:1023",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Variable Grass": {
- "ID": "dantheman999.VariableGrass",
- "Default | UpdateKey": "GitHub:dantheman999301/StardewMods"
- },
-
- "Vertical Toolbar": {
- "ID": "SB_VerticalToolMenu",
- "Default | UpdateKey": "Nexus:943"
- },
-
- "WakeUp": {
- "FormerIDs": "{EntryDll: 'WakeUp.dll'}",
- "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "Wallpaper Fix": {
- "FormerIDs": "{EntryDll: 'WallpaperFix.dll'}",
- "Default | UpdateKey": "Chucklefish:4211",
- "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "WarpAnimals": {
- "ID": "Symen.WarpAnimals",
- "Default | UpdateKey": "Nexus:1400"
- },
-
- "Weather Controller": {
- "FormerIDs": "{EntryDll: 'WeatherController.dll'}",
- "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "What Farm Cave / WhatAMush": {
- "ID": "WhatAMush",
- "Default | UpdateKey": "Nexus:1097"
- },
-
- "WHats Up": {
- "ID": "wHatsUp",
- "Default | UpdateKey": "Nexus:1082"
- },
-
- "Winter Grass": {
- "ID": "cat.wintergrass",
- "Default | UpdateKey": "Nexus:1601"
- },
-
- "Wonderful Farm Life": {
- "FormerIDs": "{EntryDll: 'WonderfulFarmLife.dll'}",
- "~1.0 | Status": "AssumeBroken" // broke in SDV 1.1 or 1.11
- },
-
- "XmlSerializerRetool": {
- "FormerIDs": "{EntryDll: 'XmlSerializerRetool.dll'}",
- "~ | Status": "Obsolete",
- "~ | StatusReasonPhrase": "it's no longer maintained or used."
- },
-
- "Xnb Loader": {
- "ID": "Entoarox.XnbLoader",
- "~1.1.10 | UpdateKey": "Chucklefish:4506", // only enable update checks up to 1.1.10 by request (has its own update-check feature)
- "~1.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.0
- },
-
- "zDailyIncrease": {
- "ID": "zdailyincrease",
- "MapRemoteVersions": { "1.3.5": "1.3.4" }, // not updated in manifest
- "Default | UpdateKey": "Chucklefish:4247",
- "~1.2 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Zoom Out Extreme": {
- "ID": "RockinMods.ZoomMod",
- "FormerIDs": "ZoomMod", // changed circa 1.2.1
- "Default | UpdateKey": "Nexus:1326",
- "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Zoryn's Better RNG": {
- "ID": "Zoryn.BetterRNG",
- "FormerIDs": "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Zoryn's Calendar Anywhere": {
- "ID": "Zoryn.CalendarAnywhere",
- "FormerIDs": "a41c01cd-0437-43eb-944f-78cb5a53002a", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Zoryn's Durable Fences": {
- "ID": "Zoryn.DurableFences",
- "FormerIDs": "56d3439c-7b9b-497e-9496-0c4890e8a00e", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods"
- },
-
- "Zoryn's Health Bars": {
- "ID": "Zoryn.HealthBars",
- "FormerIDs": "{EntryDll: 'HealthBars.dll'}", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Zoryn's Fishing Mod": {
- "ID": "Zoryn.FishingMod",
- "FormerIDs": "fa277b1f-265e-47c3-a84f-cd320cc74949", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods"
- },
-
- "Zoryn's Junimo Deposit Anywhere": {
- "ID": "Zoryn.JunimoDepositAnywhere",
- "FormerIDs": "f93a4fe8-cade-4146-9335-b5f82fbbf7bc", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
- "~1.7 | Status": "AssumeBroken" // broke in SDV 1.2
- },
-
- "Zoryn's Movement Mod": {
- "ID": "Zoryn.MovementModifier",
- "FormerIDs": "8a632929-8335-484f-87dd-c29d2ba3215d", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
- },
+ /**
+ * The console color theme to use. The possible values are:
+ * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows.
+ * - LightBackground: use darker text colors that look better on a white or light background.
+ * - DarkBackground: use lighter text colors that look better on a black or dark background.
+ */
+ "ColorScheme": "AutoDetect",
- "Zoryn's Regen Mod": {
- "ID": "Zoryn.RegenMod",
- "FormerIDs": "dfac4383-1b6b-4f33-ae4e-37fc23e5252e", // changed in 1.6
- "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods",
- "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2
- }
- }
+ /**
+ * The mod IDs SMAPI should ignore when performing update checks or validating update keys.
+ */
+ "SuppressUpdateChecks": [
+ "SMAPI.ConsoleCommands",
+ "SMAPI.SaveBackup"
+ ]
}
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index edddbd2a..0d0a5fe9 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -27,6 +27,7 @@
<ApplicationVersion>1.0.0.%2a</ApplicationVersion>
<UseApplicationTrust>false</UseApplicationTrust>
<BootstrapperEnabled>true</BootstrapperEnabled>
+ <LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
<PlatformTarget>x86</PlatformTarget>
@@ -42,7 +43,7 @@
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<OutputPath>$(SolutionDir)\..\bin\Release\SMAPI</OutputPath>
- <DocumentationFile>$(SolutionDir)\..\bin\Debug\SMAPI\StardewModdingAPI.xml</DocumentationFile>
+ <DocumentationFile>$(SolutionDir)\..\bin\Release\SMAPI\StardewModdingAPI.xml</DocumentationFile>
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
@@ -53,16 +54,20 @@
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
- <Reference Include="Mono.Cecil, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
- <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.dll</HintPath>
+ <Reference Include="0Harmony, Version=1.0.9.1, Culture=neutral, processorArchitecture=MSIL">
+ <SpecificVersion>False</SpecificVersion>
+ <HintPath>..\lib\0Harmony.dll</HintPath>
+ </Reference>
+ <Reference Include="Mono.Cecil, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL">
+ <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.dll</HintPath>
<Private>True</Private>
</Reference>
- <Reference Include="Mono.Cecil.Mdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
- <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Mdb.dll</HintPath>
+ <Reference Include="Mono.Cecil.Mdb, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL">
+ <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.Mdb.dll</HintPath>
<Private>True</Private>
</Reference>
- <Reference Include="Mono.Cecil.Pdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
- <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Pdb.dll</HintPath>
+ <Reference Include="Mono.Cecil.Pdb, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL">
+ <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.Pdb.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
@@ -72,7 +77,6 @@
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
- <Reference Include="System.Management" Condition="$(OS) == 'Windows_NT'" />
<Reference Include="System.Numerics">
<Private>True</Private>
</Reference>
@@ -86,20 +90,55 @@
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
+ <Compile Include="Events\GameLoopUpdatedEventArgs.cs" />
+ <Compile Include="Events\GameLoopLaunchedEventArgs.cs" />
+ <Compile Include="Events\InputMouseWheelScrolledEventArgs.cs" />
+ <Compile Include="Events\InputCursorMovedEventArgs.cs" />
+ <Compile Include="Events\InputButtonReleasedEventArgs.cs" />
+ <Compile Include="Events\InputButtonPressedEventArgs.cs" />
+ <Compile Include="Events\EventArgsLocationBuildingsChanged.cs" />
+ <Compile Include="Events\IInputEvents.cs" />
+ <Compile Include="Events\IGameLoopEvents.cs" />
+ <Compile Include="Events\IWorldEvents.cs" />
+ <Compile Include="Events\MultiplayerEvents.cs" />
+ <Compile Include="Events\WorldDebrisListChangedEventArgs.cs" />
+ <Compile Include="Events\GameLoopUpdatingEventArgs.cs" />
+ <Compile Include="Events\WorldNpcListChangedEventArgs.cs" />
+ <Compile Include="Events\WorldLargeTerrainFeatureListChangedEventArgs.cs" />
+ <Compile Include="Events\WorldTerrainFeatureListChangedEventArgs.cs" />
+ <Compile Include="Events\WorldBuildingListChangedEventArgs.cs" />
+ <Compile Include="Events\WorldLocationListChangedEventArgs.cs" />
+ <Compile Include="Events\WorldObjectListChangedEventArgs.cs" />
+ <Compile Include="Framework\ContentManagers\BaseContentManager.cs" />
+ <Compile Include="Framework\ContentManagers\GameContentManager.cs" />
+ <Compile Include="Framework\ContentManagers\IContentManager.cs" />
+ <Compile Include="Framework\ContentManagers\ModContentManager.cs" />
+ <Compile Include="Framework\Models\ModFolderExport.cs" />
+ <Compile Include="Framework\ModLoading\TypeReferenceComparer.cs" />
+ <Compile Include="Framework\Patching\GamePatcher.cs" />
+ <Compile Include="Framework\Patching\IHarmonyPatch.cs" />
+ <Compile Include="Framework\Serialisation\ColorConverter.cs" />
+ <Compile Include="Framework\Serialisation\PointConverter.cs" />
+ <Compile Include="Framework\Serialisation\RectangleConverter.cs" />
+ <Compile Include="Framework\Events\ModEventsBase.cs" />
<Compile Include="Framework\Events\EventManager.cs" />
+ <Compile Include="Events\IModEvents.cs" />
<Compile Include="Framework\Events\ManagedEvent.cs" />
<Compile Include="Events\SpecialisedEvents.cs" />
<Compile Include="Framework\ContentPack.cs" />
<Compile Include="Framework\Content\ContentCache.cs" />
<Compile Include="Framework\Events\ManagedEventBase.cs" />
- <Compile Include="Framework\Input\InputState.cs" />
+ <Compile Include="Framework\Events\ModEvents.cs" />
+ <Compile Include="Framework\Events\ModGameLoopEvents.cs" />
+ <Compile Include="Framework\Events\ModInputEvents.cs" />
+ <Compile Include="Framework\Input\GamePadStateBuilder.cs" />
+ <Compile Include="Framework\ModHelpers\InputHelper.cs" />
+ <Compile Include="Framework\StateTracking\Comparers\GenericEqualsComparer.cs" />
+ <Compile Include="Framework\WatcherCore.cs" />
+ <Compile Include="IInputHelper.cs" />
+ <Compile Include="Framework\Input\SInputState.cs" />
<Compile Include="Framework\Input\InputStatus.cs" />
- <Compile Include="Framework\LegacyManifestVersion.cs" />
- <Compile Include="Framework\ModData\ModDatabase.cs" />
- <Compile Include="Framework\ModData\ModDataField.cs" />
- <Compile Include="Framework\ModData\ModDataFieldKey.cs" />
- <Compile Include="Framework\ModData\ParsedModDataRecord.cs" />
- <Compile Include="Framework\Models\ManifestContentPackFor.cs" />
+ <Compile Include="Framework\ModHelpers\MultiplayerHelper.cs" />
<Compile Include="Framework\ModLoading\Finders\EventFinder.cs" />
<Compile Include="Framework\ModLoading\Finders\FieldFinder.cs" />
<Compile Include="Framework\ModLoading\Finders\MethodFinder.cs" />
@@ -109,7 +148,7 @@
<Compile Include="Framework\ModLoading\IInstructionHandler.cs" />
<Compile Include="Framework\ModLoading\IncompatibleInstructionException.cs" />
<Compile Include="Framework\ModLoading\InstructionHandleResult.cs" />
- <Compile Include="Framework\ModLoading\Platform.cs" />
+ <Compile Include="Framework\ModLoading\ModWarning.cs" />
<Compile Include="Framework\ModLoading\PlatformAssemblyMap.cs" />
<Compile Include="Framework\ModLoading\RewriteHelper.cs" />
<Compile Include="Framework\ModLoading\Rewriters\FieldReplaceRewriter.cs" />
@@ -119,22 +158,32 @@
<Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" />
<Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" />
<Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" />
- <Compile Include="Framework\ContentManagerShim.cs" />
<Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" />
<Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" />
+ <Compile Include="Framework\Events\ModWorldEvents.cs" />
<Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" />
<Compile Include="Framework\Reflection\InterfaceProxyFactory.cs" />
- <Compile Include="Framework\Serialisation\SmapiConverters\ManifestContentPackForConverter.cs" />
- <Compile Include="Framework\Serialisation\SmapiConverters\ManifestDependencyArrayConverter.cs" />
- <Compile Include="Framework\Serialisation\SmapiConverters\SemanticVersionConverter.cs" />
- <Compile Include="Framework\Serialisation\SimpleReadOnlyConverter.cs" />
- <Compile Include="Framework\Serialisation\CrossplatformConverters\RectangleConverter.cs" />
- <Compile Include="Framework\Serialisation\CrossplatformConverters\ColorConverter.cs" />
- <Compile Include="Framework\Serialisation\CrossplatformConverters\PointConverter.cs" />
+ <Compile Include="Framework\RewriteFacades\SpriteBatchMethods.cs" />
+ <Compile Include="Framework\SMultiplayer.cs" />
+ <Compile Include="Framework\StateTracking\Comparers\EquatableComparer.cs" />
+ <Compile Include="Framework\StateTracking\Comparers\ObjectReferenceComparer.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\BaseDisposableWatcher.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\ComparableWatcher.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\NetDictionaryWatcher.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\NetValueWatcher.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\NetCollectionWatcher.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\ObservableCollectionWatcher.cs" />
+ <Compile Include="Framework\StateTracking\FieldWatchers\WatcherFactory.cs" />
+ <Compile Include="Framework\StateTracking\ICollectionWatcher.cs" />
+ <Compile Include="Framework\StateTracking\IDictionaryWatcher.cs" />
+ <Compile Include="Framework\StateTracking\IValueWatcher.cs" />
+ <Compile Include="Framework\StateTracking\IWatcher.cs" />
+ <Compile Include="Framework\StateTracking\WorldLocationsTracker.cs" />
+ <Compile Include="Framework\StateTracking\LocationTracker.cs" />
+ <Compile Include="Framework\StateTracking\PlayerTracker.cs" />
<Compile Include="Framework\Utilities\ContextHash.cs" />
- <Compile Include="Framework\Utilities\PathUtilities.cs" />
<Compile Include="IContentPack.cs" />
- <Compile Include="IManifestContentPackFor.cs" />
+ <Compile Include="IMultiplayerHelper.cs" />
<Compile Include="IReflectedField.cs" />
<Compile Include="IReflectedMethod.cs" />
<Compile Include="IReflectedProperty.cs" />
@@ -155,8 +204,8 @@
<Compile Include="Events\EventArgsControllerButtonReleased.cs" />
<Compile Include="Events\EventArgsControllerTriggerPressed.cs" />
<Compile Include="Events\EventArgsControllerTriggerReleased.cs" />
- <Compile Include="Events\EventArgsCurrentLocationChanged.cs" />
- <Compile Include="Events\EventArgsGameLocationsChanged.cs" />
+ <Compile Include="Events\EventArgsPlayerWarped.cs" />
+ <Compile Include="Events\EventArgsLocationsChanged.cs" />
<Compile Include="Events\EventArgsIntChanged.cs" />
<Compile Include="Events\EventArgsInventoryChanged.cs" />
<Compile Include="Events\EventArgsKeyboardStateChanged.cs" />
@@ -191,16 +240,11 @@
<Compile Include="Context.cs" />
<Compile Include="Framework\Logging\ConsoleInterceptionManager.cs" />
<Compile Include="Framework\Logging\InterceptingTextWriter.cs" />
- <Compile Include="Framework\Models\ManifestDependency.cs" />
- <Compile Include="Framework\ModData\ModStatus.cs" />
<Compile Include="Framework\Models\SConfig.cs" />
<Compile Include="Framework\ModLoading\ModMetadata.cs" />
<Compile Include="Framework\Reflection\ReflectedProperty.cs" />
<Compile Include="Framework\RequestExitDelegate.cs" />
- <Compile Include="Framework\ContentCore.cs" />
- <Compile Include="Framework\Exceptions\SParseException.cs" />
- <Compile Include="Framework\Serialisation\JsonHelper.cs" />
- <Compile Include="Framework\Serialisation\SmapiConverters\StringEnumConverter.cs" />
+ <Compile Include="Framework\ContentCoordinator.cs" />
<Compile Include="IAssetEditor.cs" />
<Compile Include="IAssetInfo.cs" />
<Compile Include="IAssetLoader.cs" />
@@ -209,7 +253,6 @@
<Compile Include="IAssetDataForDictionary.cs" />
<Compile Include="IAssetDataForImage.cs" />
<Compile Include="IContentHelper.cs" />
- <Compile Include="IManifestDependency.cs" />
<Compile Include="IModRegistry.cs" />
<Compile Include="Events\LocationEvents.cs" />
<Compile Include="Events\MenuEvents.cs" />
@@ -220,36 +263,29 @@
<Compile Include="Framework\DeprecationLevel.cs" />
<Compile Include="Framework\DeprecationManager.cs" />
<Compile Include="Framework\InternalExtensions.cs" />
- <Compile Include="Framework\ModData\ModDataRecord.cs" />
<Compile Include="Framework\ModLoading\AssemblyLoader.cs" />
<Compile Include="Framework\Reflection\CacheEntry.cs" />
<Compile Include="Framework\Reflection\ReflectedField.cs" />
<Compile Include="Framework\Reflection\ReflectedMethod.cs" />
<Compile Include="Framework\Reflection\Reflector.cs" />
- <Compile Include="IManifest.cs" />
<Compile Include="IMod.cs" />
<Compile Include="IModHelper.cs" />
<Compile Include="IModLinked.cs" />
<Compile Include="Framework\Logging\LogFileManager.cs" />
- <Compile Include="IPrivateProperty.cs" />
- <Compile Include="ISemanticVersion.cs" />
<Compile Include="ITranslationHelper.cs" />
<Compile Include="LogLevel.cs" />
<Compile Include="Framework\ModRegistry.cs" />
- <Compile Include="Framework\WebApiClient.cs" />
<Compile Include="IMonitor.cs" />
<Compile Include="Events\ChangeType.cs" />
<Compile Include="Events\ItemStackChange.cs" />
<Compile Include="Framework\Monitor.cs" />
- <Compile Include="Framework\Models\Manifest.cs" />
<Compile Include="Metadata\InstructionMetadata.cs" />
<Compile Include="Mod.cs" />
<Compile Include="PatchMode.cs" />
+ <Compile Include="GamePlatform.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Framework\SGame.cs" />
- <Compile Include="IPrivateField.cs" />
- <Compile Include="IPrivateMethod.cs" />
<Compile Include="IReflectionHelper.cs" />
<Compile Include="SemanticVersion.cs" />
<Compile Include="Translation.cs" />
@@ -263,13 +299,17 @@
<SubType>Designer</SubType>
</None>
<Content Include="StardewModdingAPI.config.json">
- <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </Content>
+ <Content Include="..\SMAPI.Web\wwwroot\StardewModdingAPI.metadata.json">
+ <Link>StardewModdingAPI.metadata.json</Link>
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="icon.ico" />
<Content Include="steam_appid.txt">
- <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
@@ -284,13 +324,27 @@
<Install>false</Install>
</BootstrapperPackage>
</ItemGroup>
- <Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
<ItemGroup>
- <ProjectReference Include="..\SMAPI.AssemblyRewriters\StardewModdingAPI.AssemblyRewriters.csproj">
- <Project>{10db0676-9fc1-4771-a2c8-e2519f091e49}</Project>
- <Name>StardewModdingAPI.AssemblyRewriters</Name>
+ <Analyzer Include="..\SMAPI.ModBuildConfig.Analyzer\bin\netstandard1.3\StardewModdingAPI.ModBuildConfig.Analyzer.dll" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj">
+ <Project>{d5cfd923-37f1-4bc3-9be8-e506e202ac28}</Project>
+ <Name>StardewModdingAPI.Toolkit.CoreInterfaces</Name>
+ </ProjectReference>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj">
+ <Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project>
+ <Name>StardewModdingAPI.Toolkit</Name>
</ProjectReference>
</ItemGroup>
+ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
+ <Import Project="..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets" Condition="Exists('..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets')" />
+ <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
+ <PropertyGroup>
+ <ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
+ </PropertyGroup>
+ <Error Condition="!Exists('..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets'))" />
+ </Target>
</Project> \ No newline at end of file
diff --git a/src/SMAPI/packages.config b/src/SMAPI/packages.config
index 3e876922..84c6bed0 100644
--- a/src/SMAPI/packages.config
+++ b/src/SMAPI/packages.config
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
- <package id="Mono.Cecil" version="0.9.6.4" targetFramework="net45" />
+ <package id="LargeAddressAware" version="1.0.3" targetFramework="net45" />
+ <package id="Mono.Cecil" version="0.10.0" targetFramework="net45" />
<package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" />
</packages> \ No newline at end of file
diff --git a/src/SMAPI/IManifest.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifest.cs
index 183ac105..7375f005 100644
--- a/src/SMAPI/IManifest.cs
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifest.cs
@@ -26,7 +26,7 @@ namespace StardewModdingAPI
/// <summary>The unique mod ID.</summary>
string UniqueID { get; }
- /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method. Mutually exclusive with <see cref="EntryDll"/>.</summary>
+ /// <summary>The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary>
string EntryDll { get; }
/// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="EntryDll"/>.</summary>
@@ -36,7 +36,7 @@ namespace StardewModdingAPI
IManifestDependency[] Dependencies { get; }
/// <summary>The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</summary>
- string[] UpdateKeys { get; set; }
+ string[] UpdateKeys { get; }
/// <summary>Any manifest fields which didn't match a valid field.</summary>
IDictionary<string, object> ExtraFields { get; }
diff --git a/src/SMAPI/IManifestContentPackFor.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs
index f05a3873..f05a3873 100644
--- a/src/SMAPI/IManifestContentPackFor.cs
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs
diff --git a/src/SMAPI/IManifestDependency.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifestDependency.cs
index e86cd1f4..e86cd1f4 100644
--- a/src/SMAPI/IManifestDependency.cs
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/IManifestDependency.cs
diff --git a/src/SMAPI/ISemanticVersion.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
index 0483c97b..961ef777 100644
--- a/src/SMAPI/ISemanticVersion.cs
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
@@ -24,6 +24,9 @@ namespace StardewModdingAPI
/*********
** Accessors
*********/
+ /// <summary>Whether this is a pre-release version.</summary>
+ bool IsPrerelease();
+
/// <summary>Get whether this version is older than the specified version.</summary>
/// <param name="other">The version to compare with this instance.</param>
bool IsOlderThan(ISemanticVersion other);
diff --git a/src/StardewModdingAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..a29ba6cf
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Reflection;
+
+[assembly: AssemblyTitle("SMAPI.Toolkit.CoreInterfaces")]
+[assembly: AssemblyDescription("Provides toolkit interfaces which are available to SMAPI mods.")]
diff --git a/src/StardewModdingAPI.Toolkit.CoreInterfaces/StardewModdingAPI.Toolkit.CoreInterfaces.csproj b/src/StardewModdingAPI.Toolkit.CoreInterfaces/StardewModdingAPI.Toolkit.CoreInterfaces.csproj
new file mode 100644
index 00000000..525931e5
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/StardewModdingAPI.Toolkit.CoreInterfaces.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks>
+ <RootNamespace>StardewModdingAPI</RootNamespace>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces</OutputPath>
+ <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces\$(TargetFramework)\StardewModdingAPI.Toolkit.CoreInterfaces.xml</DocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" />
+ </ItemGroup>
+
+ <Import Project="..\..\build\common.targets" />
+
+</Project>
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
new file mode 100644
index 00000000..f3f22b93
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
@@ -0,0 +1,71 @@
+using System;
+using Newtonsoft.Json;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Metadata about a mod.</summary>
+ public class ModEntryModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's unique ID (if known).</summary>
+ public string ID { get; set; }
+
+ /// <summary>The main version.</summary>
+ public ModEntryVersionModel Main { get; set; }
+
+ /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary>
+ public ModEntryVersionModel Optional { get; set; }
+
+ /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary>
+ public ModEntryVersionModel Unofficial { get; set; }
+
+ /// <summary>Optional extended data which isn't needed for update checks.</summary>
+ public ModExtendedMetadataModel Metadata { get; set; }
+
+ /// <summary>The errors that occurred while fetching update data.</summary>
+ public string[] Errors { get; set; } = new string[0];
+
+ /****
+ ** Backwards-compatible fields
+ ****/
+ /// <summary>The mod's latest version number.</summary>
+ [Obsolete("Use " + nameof(ModEntryModel.Main))]
+ [JsonProperty]
+ internal string Version { get; private set; }
+
+ /// <summary>The mod's web URL.</summary>
+ [Obsolete("Use " + nameof(ModEntryModel.Main))]
+ [JsonProperty]
+ internal string Url { get; private set; }
+
+ /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary>
+ [Obsolete("Use " + nameof(ModEntryModel.Optional))]
+ [JsonProperty]
+ internal string PreviewVersion { get; private set; }
+
+ /// <summary>The web URL to the mod's latest optional release, if newer than <see cref="Version"/>.</summary>
+ [Obsolete("Use " + nameof(ModEntryModel.Optional))]
+ [JsonProperty]
+ internal string PreviewUrl { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Set backwards-compatible fields.</summary>
+ /// <param name="version">The requested API version.</param>
+ public void SetBackwardsCompatibility(ISemanticVersion version)
+ {
+ if (version.IsOlderThan("2.6-beta.19"))
+ {
+ this.Version = this.Main?.Version?.ToString();
+ this.Url = this.Main?.Url;
+
+ this.PreviewVersion = this.Optional?.Version?.ToString();
+ this.PreviewUrl = this.Optional?.Url;
+ }
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs
new file mode 100644
index 00000000..dadb8c10
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs
@@ -0,0 +1,31 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Metadata about a version.</summary>
+ public class ModEntryVersionModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The version number.</summary>
+ public ISemanticVersion Version { get; set; }
+
+ /// <summary>The mod page URL.</summary>
+ public string Url { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModEntryVersionModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The version number.</param>
+ /// <param name="url">The mod page URL.</param>
+ public ModEntryVersionModel(ISemanticVersion version, string url)
+ {
+ this.Version = version;
+ this.Url = url;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
new file mode 100644
index 00000000..21376b36
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
@@ -0,0 +1,89 @@
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Extended metadata about a mod.</summary>
+ public class ModExtendedMetadataModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary>
+ public string[] ID { get; set; } = new string[0];
+
+ /// <summary>The mod's display name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod ID on Nexus.</summary>
+ public int? NexusID { get; set; }
+
+ /// <summary>The mod ID in the Chucklefish mod repo.</summary>
+ public int? ChucklefishID { get; set; }
+
+ /// <summary>The GitHub repository in the form 'owner/repo'.</summary>
+ public string GitHubRepo { get; set; }
+
+ /// <summary>The URL to a non-GitHub source repo.</summary>
+ public string CustomSourceUrl { get; set; }
+
+ /// <summary>The custom mod page URL (if applicable).</summary>
+ public string CustomUrl { get; set; }
+
+ /// <summary>The compatibility status.</summary>
+ [JsonConverter(typeof(StringEnumConverter))]
+ public WikiCompatibilityStatus? CompatibilityStatus { get; set; }
+
+ /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary>
+ public string CompatibilitySummary { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModExtendedMetadataModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="wiki">The mod metadata from the wiki (if available).</param>
+ /// <param name="db">The mod metadata from SMAPI's internal DB (if available).</param>
+ public ModExtendedMetadataModel(WikiCompatibilityEntry wiki, ModDataRecord db)
+ {
+ // wiki data
+ if (wiki != null)
+ {
+ this.ID = wiki.ID;
+ this.Name = wiki.Name;
+ this.NexusID = wiki.NexusID;
+ this.ChucklefishID = wiki.ChucklefishID;
+ this.GitHubRepo = wiki.GitHubRepo;
+ this.CustomSourceUrl = wiki.CustomSourceUrl;
+ this.CustomUrl = wiki.CustomUrl;
+ this.CompatibilityStatus = wiki.Status;
+ this.CompatibilitySummary = wiki.Summary;
+ }
+
+ // internal DB data
+ if (db != null)
+ {
+ this.ID = this.ID.Union(db.FormerIDs).ToArray();
+ this.Name = this.Name ?? db.DisplayName;
+ }
+ }
+
+ /// <summary>Get update keys based on the metadata.</summary>
+ public IEnumerable<string> GetUpdateKeys()
+ {
+ if (this.NexusID.HasValue)
+ yield return $"Nexus:{this.NexusID}";
+ if (this.ChucklefishID.HasValue)
+ yield return $"Chucklefish:{this.ChucklefishID}";
+ if (this.GitHubRepo != null)
+ yield return $"GitHub:{this.GitHubRepo}";
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs
new file mode 100644
index 00000000..df0d8457
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Specifies mods whose update-check info to fetch.</summary>
+ public class ModSearchModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The namespaced mod keys to search.</summary>
+ [Obsolete]
+ public string[] ModKeys { get; set; }
+
+ /// <summary>The mods for which to find data.</summary>
+ public ModSearchEntryModel[] Mods { get; set; }
+
+ /// <summary>Whether to include extended metadata for each mod.</summary>
+ public bool IncludeExtendedMetadata { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public ModSearchModel()
+ {
+ // needed for JSON deserialising
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mods">The mods to search.</param>
+ /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
+ public ModSearchModel(ModSearchEntryModel[] mods, bool includeExtendedMetadata)
+ {
+ this.Mods = mods.ToArray();
+ this.IncludeExtendedMetadata = includeExtendedMetadata;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
new file mode 100644
index 00000000..bca47647
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
@@ -0,0 +1,34 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Specifies the identifiers for a mod to match.</summary>
+ public class ModSearchEntryModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique mod ID.</summary>
+ public string ID { get; set; }
+
+ /// <summary>The namespaced mod update keys (if available).</summary>
+ public string[] UpdateKeys { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public ModSearchEntryModel()
+ {
+ // needed for JSON deserialising
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="id">The unique mod ID.</param>
+ /// <param name="updateKeys">The namespaced mod update keys (if available).</param>
+ public ModSearchEntryModel(string id, string[] updateKeys)
+ {
+ this.ID = id;
+ this.UpdateKeys = updateKeys ?? new string[0];
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/WebApiClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs
index 7f0122cf..0ecd9664 100644
--- a/src/SMAPI/Framework/WebApiClient.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs
@@ -1,13 +1,14 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Net;
using Newtonsoft.Json;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit.Serialisation;
-namespace StardewModdingAPI.Framework
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
{
/// <summary>Provides methods for interacting with the SMAPI web API.</summary>
- internal class WebApiClient
+ public class WebApiClient
{
/*********
** Properties
@@ -18,6 +19,9 @@ namespace StardewModdingAPI.Framework
/// <summary>The API version number.</summary>
private readonly ISemanticVersion Version;
+ /// <summary>The JSON serializer settings to use.</summary>
+ private readonly JsonSerializerSettings JsonSettings = new JsonHelper().JsonSettings;
+
/*********
** Public methods
@@ -27,21 +31,19 @@ namespace StardewModdingAPI.Framework
/// <param name="version">The web API version.</param>
public WebApiClient(string baseUrl, ISemanticVersion version)
{
-#if !SMAPI_FOR_WINDOWS
- baseUrl = baseUrl.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac
-#endif
this.BaseUrl = new Uri(baseUrl);
this.Version = version;
}
- /// <summary>Get the latest SMAPI version.</summary>
- /// <param name="modKeys">The mod keys for which to fetch the latest version.</param>
- public IDictionary<string, ModInfoModel> GetModInfo(params string[] modKeys)
+ /// <summary>Get metadata about a set of mods from the web API.</summary>
+ /// <param name="mods">The mod keys for which to fetch the latest version.</param>
+ /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
+ public IDictionary<string, ModEntryModel> GetModInfo(ModSearchEntryModel[] mods, bool includeExtendedMetadata = false)
{
- return this.Post<ModSearchModel, Dictionary<string, ModInfoModel>>(
+ return this.Post<ModSearchModel, ModEntryModel[]>(
$"v{this.Version}/mods",
- new ModSearchModel(modKeys, allowInvalidVersions: true)
- );
+ new ModSearchModel(mods, includeExtendedMetadata)
+ ).ToDictionary(p => p.ID);
}
@@ -55,9 +57,7 @@ namespace StardewModdingAPI.Framework
/// <param name="content">The body content to post.</param>
private TResult Post<TBody, TResult>(string url, TBody content)
{
- /***
- ** Note: avoid HttpClient for Mac compatibility.
- ***/
+ // note: avoid HttpClient for Mac compatibility
using (WebClient client = new WebClient())
{
Uri fullUrl = new Uri(this.BaseUrl, url);
@@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework
client.Headers["Content-Type"] = "application/json";
client.Headers["User-Agent"] = $"SMAPI/{this.Version}";
string response = client.UploadString(fullUrl, data);
- return JsonConvert.DeserializeObject<TResult>(response);
+ return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings);
}
}
}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs
new file mode 100644
index 00000000..d0da42df
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading.Tasks;
+using HtmlAgilityPack;
+using Pathoschild.Http.Client;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>An HTTP client for fetching mod metadata from the wiki compatibility list.</summary>
+ public class WikiCompatibilityClient : IDisposable
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="userAgent">The user agent for the wiki API.</param>
+ /// <param name="baseUrl">The base URL for the wiki API.</param>
+ public WikiCompatibilityClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php")
+ {
+ this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ }
+
+ /// <summary>Fetch mod compatibility entries.</summary>
+ public async Task<WikiCompatibilityEntry[]> FetchAsync()
+ {
+ // fetch HTML
+ ResponseModel response = await this.Client
+ .GetAsync("")
+ .WithArguments(new
+ {
+ action = "parse",
+ page = "Modding:SMAPI_compatibility",
+ format = "json"
+ })
+ .As<ResponseModel>();
+ string html = response.Parse.Text["*"];
+
+ // parse HTML
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ // find mod entries
+ HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']");
+ if (modNodes == null)
+ throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found.");
+
+ // parse
+ return this.ParseEntries(modNodes).ToArray();
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client?.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Parse valid mod compatibility entries.</summary>
+ /// <param name="nodes">The HTML compatibility entries.</param>
+ private IEnumerable<WikiCompatibilityEntry> ParseEntries(IEnumerable<HtmlNode> nodes)
+ {
+ foreach (HtmlNode node in nodes)
+ {
+ // parse status
+ WikiCompatibilityStatus status;
+ {
+ string rawStatus = node.GetAttributeValue("data-status", null);
+ if (rawStatus == null)
+ continue; // not a mod node?
+ if (!Enum.TryParse(rawStatus, true, out status))
+ throw new InvalidOperationException($"Unknown status '{rawStatus}' when parsing compatibility list.");
+ }
+
+ // parse unofficial version
+ ISemanticVersion unofficialVersion = null;
+ {
+ string rawUnofficialVersion = node.GetAttributeValue("data-unofficial-version", null);
+ SemanticVersion.TryParse(rawUnofficialVersion, out unofficialVersion);
+ }
+
+ // parse other fields
+ string name = node.Descendants("td").FirstOrDefault()?.InnerText?.Trim();
+ string summary = node.Descendants("td").FirstOrDefault(p => p.GetAttributeValue("class", null) == "summary")?.InnerText.Trim();
+ string[] ids = this.GetAttribute(node, "data-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0];
+ int? nexusID = this.GetNullableIntAttribute(node, "data-nexus-id");
+ int? chucklefishID = this.GetNullableIntAttribute(node, "data-chucklefish-id");
+ string githubRepo = this.GetAttribute(node, "data-github");
+ string customSourceUrl = this.GetAttribute(node, "data-custom-source");
+ string customUrl = this.GetAttribute(node, "data-custom-url");
+
+ // yield model
+ yield return new WikiCompatibilityEntry
+ {
+ ID = ids,
+ Name = name,
+ Status = status,
+ NexusID = nexusID,
+ ChucklefishID = chucklefishID,
+ GitHubRepo = githubRepo,
+ CustomSourceUrl = customSourceUrl,
+ CustomUrl = customUrl,
+ UnofficialVersion = unofficialVersion,
+ Summary = summary
+ };
+ }
+ }
+
+ /// <summary>Get a nullable integer attribute value.</summary>
+ /// <param name="node">The HTML node.</param>
+ /// <param name="attributeName">The attribute name.</param>
+ private int? GetNullableIntAttribute(HtmlNode node, string attributeName)
+ {
+ string raw = this.GetAttribute(node, attributeName);
+ if (raw != null && int.TryParse(raw, out int value))
+ return value;
+ return null;
+ }
+
+ /// <summary>Get a strings attribute value.</summary>
+ /// <param name="node">The HTML node.</param>
+ /// <param name="attributeName">The attribute name.</param>
+ private string GetAttribute(HtmlNode node, string attributeName)
+ {
+ string raw = node.GetAttributeValue(attributeName, null);
+ if (raw != null)
+ raw = HtmlEntity.DeEntitize(raw);
+ return raw;
+ }
+
+ /// <summary>The response model for the MediaWiki parse API.</summary>
+ [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
+ [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
+ private class ResponseModel
+ {
+ /// <summary>The parse API results.</summary>
+ public ResponseParseModel Parse { get; set; }
+ }
+
+ /// <summary>The inner response model for the MediaWiki parse API.</summary>
+ [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
+ [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")]
+ [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
+ private class ResponseParseModel
+ {
+ /// <summary>The parsed text.</summary>
+ public IDictionary<string, string> Text { get; set; }
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs
new file mode 100644
index 00000000..8bc66e20
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs
@@ -0,0 +1,36 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>An entry in the mod compatibility list.</summary>
+ public class WikiCompatibilityEntry
+ {
+ /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary>
+ public string[] ID { get; set; }
+
+ /// <summary>The mod's display name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod ID on Nexus.</summary>
+ public int? NexusID { get; set; }
+
+ /// <summary>The mod ID in the Chucklefish mod repo.</summary>
+ public int? ChucklefishID { get; set; }
+
+ /// <summary>The GitHub repository in the form 'owner/repo'.</summary>
+ public string GitHubRepo { get; set; }
+
+ /// <summary>The URL to a non-GitHub source repo.</summary>
+ public string CustomSourceUrl { get; set; }
+
+ /// <summary>The custom mod page URL (if applicable).</summary>
+ public string CustomUrl { get; set; }
+
+ /// <summary>The version of the latest unofficial update, if applicable.</summary>
+ public ISemanticVersion UnofficialVersion { get; set; }
+
+ /// <summary>The compatibility status.</summary>
+ public WikiCompatibilityStatus Status { get; set; }
+
+ /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary>
+ public string Summary { get; set; }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs
new file mode 100644
index 00000000..a1d2dfae
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs
@@ -0,0 +1,27 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>The compatibility status for a mod.</summary>
+ public enum WikiCompatibilityStatus
+ {
+ /// <summary>The mod is compatible.</summary>
+ Ok = 0,
+
+ /// <summary>The mod is compatible if you use an optional official download.</summary>
+ Optional = 1,
+
+ /// <summary>The mod is compatible if you use an unofficial update.</summary>
+ Unofficial = 2,
+
+ /// <summary>The mod isn't compatible, but the player can fix it or there's a good alternative.</summary>
+ Workaround = 3,
+
+ /// <summary>The mod isn't compatible.</summary>
+ Broken = 4,
+
+ /// <summary>The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely.</summary>
+ Abandoned = 5,
+
+ /// <summary>The mod is no longer needed and should be removed.</summary>
+ Obsolete = 6
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModData/MetadataModel.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/MetadataModel.cs
new file mode 100644
index 00000000..ef6d4dd9
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/MetadataModel.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The SMAPI predefined metadata.</summary>
+ internal class MetadataModel
+ {
+ /********
+ ** Accessors
+ ********/
+ /// <summary>Extra metadata about mods.</summary>
+ public IDictionary<string, ModDataModel> ModData { get; set; }
+ }
+}
diff --git a/src/SMAPI/Framework/ModData/ModDataField.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataField.cs
index df906103..b3954693 100644
--- a/src/SMAPI/Framework/ModData/ModDataField.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataField.cs
@@ -1,9 +1,9 @@
using System.Linq;
-namespace StardewModdingAPI.Framework.ModData
+namespace StardewModdingAPI.Toolkit.Framework.ModData
{
/// <summary>A versioned mod metadata field.</summary>
- internal class ModDataField
+ public class ModDataField
{
/*********
** Accessors
diff --git a/src/SMAPI/Framework/ModData/ModDataFieldKey.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs
index f68f575c..09dd0cc5 100644
--- a/src/SMAPI/Framework/ModData/ModDataFieldKey.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Framework.ModData
+namespace StardewModdingAPI.Toolkit.Framework.ModData
{
/// <summary>The valid field keys.</summary>
public enum ModDataFieldKey
diff --git a/src/SMAPI/Framework/ModData/ModDataRecord.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataModel.cs
index 56275f53..e2b3ec1d 100644
--- a/src/SMAPI/Framework/ModData/ModDataRecord.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataModel.cs
@@ -5,20 +5,12 @@ using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
-namespace StardewModdingAPI.Framework.ModData
+namespace StardewModdingAPI.Toolkit.Framework.ModData
{
- /// <summary>Raw mod metadata from SMAPI's internal mod list.</summary>
- internal class ModDataRecord
+ /// <summary>The raw mod metadata from SMAPI's internal mod list.</summary>
+ internal class ModDataModel
{
/*********
- ** Properties
- *********/
- /// <summary>This field stores properties that aren't mapped to another field before they're parsed into <see cref="Fields"/>.</summary>
- [JsonExtensionData]
- private IDictionary<string, JToken> ExtensionData;
-
-
- /*********
** Accessors
*********/
/// <summary>The mod's current unique ID.</summary>
@@ -44,6 +36,10 @@ namespace StardewModdingAPI.Framework.ModData
/// <summary>Maps remote versions to a semantic version for update checks.</summary>
public IDictionary<string, string> MapRemoteVersions { get; set; } = new Dictionary<string, string>();
+ /// <summary>This field stores properties that aren't mapped to another field before they're parsed into <see cref="Fields"/>.</summary>
+ [JsonExtensionData]
+ public IDictionary<string, JToken> ExtensionData { get; set; }
+
/// <summary>The versioned field data.</summary>
/// <remarks>
/// This maps field names to values. This should be accessed via <see cref="GetFields"/>.
@@ -104,27 +100,14 @@ namespace StardewModdingAPI.Framework.ModData
}
}
- /// <summary>Get a semantic local version for update checks.</summary>
- /// <param name="version">The remote version to normalise.</param>
- public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version)
- {
- return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version.ToString(), out string newVersion)
- ? new SemanticVersion(newVersion)
- : version;
- }
-
- /// <summary>Get a semantic remote version for update checks.</summary>
- /// <param name="version">The remote version to normalise.</param>
- public string GetRemoteVersionForUpdateChecks(string version)
+ /// <summary>Get the former mod IDs.</summary>
+ public IEnumerable<string> GetFormerIDs()
{
- // normalise version if possible
- if (SemanticVersion.TryParse(version, out ISemanticVersion parsed))
- version = parsed.ToString();
-
- // fetch remote version
- return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion)
- ? newVersion
- : version;
+ if (this.FormerIDs != null)
+ {
+ foreach (string id in this.FormerIDs.Split('|'))
+ yield return id.Trim();
+ }
}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs
new file mode 100644
index 00000000..82ac8837
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The parsed mod metadata from SMAPI's internal mod list.</summary>
+ public class ModDataRecord
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's default display name.</summary>
+ public string DisplayName { get; }
+
+ /// <summary>The mod's current unique ID.</summary>
+ public string ID { get; }
+
+ /// <summary>The former mod IDs (if any).</summary>
+ public string[] FormerIDs { get; }
+
+ /// <summary>Maps local versions to a semantic version for update checks.</summary>
+ public IDictionary<string, string> MapLocalVersions { get; }
+
+ /// <summary>Maps remote versions to a semantic version for update checks.</summary>
+ public IDictionary<string, string> MapRemoteVersions { get; }
+
+ /// <summary>The versioned field data.</summary>
+ public ModDataField[] Fields { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="displayName">The mod's default display name.</param>
+ /// <param name="model">The raw data model.</param>
+ internal ModDataRecord(string displayName, ModDataModel model)
+ {
+ this.DisplayName = displayName;
+ this.ID = model.ID;
+ this.FormerIDs = model.GetFormerIDs().ToArray();
+ this.MapLocalVersions = new Dictionary<string, string>(model.MapLocalVersions, StringComparer.InvariantCultureIgnoreCase);
+ this.MapRemoteVersions = new Dictionary<string, string>(model.MapRemoteVersions, StringComparer.InvariantCultureIgnoreCase);
+ this.Fields = model.GetFields().ToArray();
+ }
+
+ /// <summary>Get whether the mod has (or previously had) the given ID.</summary>
+ /// <param name="id">The mod ID.</param>
+ public bool HasID(string id)
+ {
+ // try main ID
+ if (this.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase))
+ return true;
+
+ // try former IDs
+ foreach (string formerID in this.FormerIDs)
+ {
+ if (formerID.Equals(id, StringComparison.InvariantCultureIgnoreCase))
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>Get a semantic local version for update checks.</summary>
+ /// <param name="version">The remote version to normalise.</param>
+ public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version)
+ {
+ return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version.ToString(), out string newVersion)
+ ? new SemanticVersion(newVersion)
+ : version;
+ }
+
+ /// <summary>Get a semantic remote version for update checks.</summary>
+ /// <param name="version">The remote version to normalise.</param>
+ public string GetRemoteVersionForUpdateChecks(string version)
+ {
+ // normalise version if possible
+ if (SemanticVersion.TryParse(version, out ISemanticVersion parsed))
+ version = parsed.ToString();
+
+ // fetch remote version
+ return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion)
+ ? newVersion
+ : version;
+ }
+
+ /// <summary>Get the possible mod IDs.</summary>
+ public IEnumerable<string> GetIDs()
+ {
+ return this.FormerIDs
+ .Concat(new[] { this.ID })
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p.Trim())
+ .Distinct();
+ }
+
+ /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary>
+ /// <param name="manifest">The manifest to match.</param>
+ public ModDataRecordVersionedFields GetVersionedFields(IManifest manifest)
+ {
+ ModDataRecordVersionedFields parsed = new ModDataRecordVersionedFields { DisplayName = this.DisplayName, DataRecord = this };
+ foreach (ModDataField field in this.Fields.Where(field => field.IsMatch(manifest)))
+ {
+ switch (field.Key)
+ {
+ // update key
+ case ModDataFieldKey.UpdateKey:
+ parsed.UpdateKey = field.Value;
+ break;
+
+ // alternative URL
+ case ModDataFieldKey.AlternativeUrl:
+ parsed.AlternativeUrl = field.Value;
+ break;
+
+ // status
+ case ModDataFieldKey.Status:
+ parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true);
+ parsed.StatusUpperVersion = field.UpperVersion;
+ break;
+
+ // status reason phrase
+ case ModDataFieldKey.StatusReasonPhrase:
+ parsed.StatusReasonPhrase = field.Value;
+ break;
+ }
+ }
+
+ return parsed;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ModData/ParsedModDataRecord.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs
index deb12bdc..237f2c66 100644
--- a/src/SMAPI/Framework/ModData/ParsedModDataRecord.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs
@@ -1,7 +1,7 @@
-namespace StardewModdingAPI.Framework.ModData
+namespace StardewModdingAPI.Toolkit.Framework.ModData
{
- /// <summary>A parsed representation of the fields from a <see cref="ModDataRecord"/> for a specific manifest.</summary>
- internal class ParsedModDataRecord
+ /// <summary>The versioned fields from a <see cref="ModDataRecord"/> for a specific manifest.</summary>
+ public class ModDataRecordVersionedFields
{
/*********
** Accessors
@@ -40,9 +40,15 @@ namespace StardewModdingAPI.Framework.ModData
/// <summary>Get a semantic remote version for update checks.</summary>
/// <param name="version">The remote version to normalise.</param>
- public string GetRemoteVersionForUpdateChecks(string version)
+ public ISemanticVersion GetRemoteVersionForUpdateChecks(ISemanticVersion version)
{
- return this.DataRecord.GetRemoteVersionForUpdateChecks(version);
+ if (version == null)
+ return null;
+
+ string rawVersion = this.DataRecord.GetRemoteVersionForUpdateChecks(version.ToString());
+ return rawVersion != null
+ ? new SemanticVersion(rawVersion)
+ : version;
}
}
}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDatabase.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDatabase.cs
new file mode 100644
index 00000000..3b98bcf1
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDatabase.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>Handles access to SMAPI's internal mod metadata list.</summary>
+ public class ModDatabase
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying mod data records indexed by default display name.</summary>
+ private readonly ModDataRecord[] Records;
+
+ /// <summary>Get an update URL for an update key (if valid).</summary>
+ private readonly Func<string, string> GetUpdateUrl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public ModDatabase()
+ : this(new ModDataRecord[0], key => null) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="records">The underlying mod data records indexed by default display name.</param>
+ /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param>
+ public ModDatabase(IEnumerable<ModDataRecord> records, Func<string, string> getUpdateUrl)
+ {
+ this.Records = records.ToArray();
+ this.GetUpdateUrl = getUpdateUrl;
+ }
+
+ /// <summary>Get all mod data records.</summary>
+ public IEnumerable<ModDataRecord> GetAll()
+ {
+ return this.Records;
+ }
+
+ /// <summary>Get a mod data record.</summary>
+ /// <param name="modID">The unique mod ID.</param>
+ public ModDataRecord Get(string modID)
+ {
+ return !string.IsNullOrWhiteSpace(modID)
+ ? this.Records.FirstOrDefault(p => p.HasID(modID))
+ : null;
+ }
+
+ /// <summary>Get the mod page URL for a mod (if available).</summary>
+ /// <param name="id">The unique mod ID.</param>
+ public string GetModPageUrlFor(string id)
+ {
+ // get update key
+ ModDataRecord record = this.Get(id);
+ ModDataField updateKeyField = record?.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey);
+ if (updateKeyField == null)
+ return null;
+
+ // get update URL
+ return this.GetUpdateUrl(updateKeyField.Value);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/ModData/ModStatus.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModStatus.cs
index 0e1d94d4..09da74bf 100644
--- a/src/SMAPI/Framework/ModData/ModStatus.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModStatus.cs
@@ -1,7 +1,7 @@
-namespace StardewModdingAPI.Framework.ModData
+namespace StardewModdingAPI.Toolkit.Framework.ModData
{
/// <summary>Indicates how SMAPI should treat a mod.</summary>
- internal enum ModStatus
+ public enum ModStatus
{
/// <summary>Don't override the status.</summary>
None,
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs
new file mode 100644
index 00000000..4aaa3f83
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModScanning
+{
+ /// <summary>The info about a mod read from its folder.</summary>
+ public class ModFolder
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The Mods subfolder containing this mod.</summary>
+ public DirectoryInfo SearchDirectory { get; }
+
+ /// <summary>The folder containing manifest.json.</summary>
+ public DirectoryInfo ActualDirectory { get; }
+
+ /// <summary>The mod manifest.</summary>
+ public Manifest Manifest { get; }
+
+ /// <summary>The error which occurred parsing the manifest, if any.</summary>
+ public string ManifestParseError { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="searchDirectory">The Mods subfolder containing this mod.</param>
+ /// <param name="actualDirectory">The folder containing manifest.json.</param>
+ /// <param name="manifest">The mod manifest.</param>
+ /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param>
+ public ModFolder(DirectoryInfo searchDirectory, DirectoryInfo actualDirectory, Manifest manifest, string manifestParseError = null)
+ {
+ this.SearchDirectory = searchDirectory;
+ this.ActualDirectory = actualDirectory;
+ this.Manifest = manifest;
+ this.ManifestParseError = manifestParseError;
+ }
+
+ /// <summary>Get the update keys for a mod.</summary>
+ /// <param name="manifest">The mod manifest.</param>
+ public IEnumerable<string> GetUpdateKeys(Manifest manifest)
+ {
+ return
+ (manifest.UpdateKeys ?? new string[0])
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .ToArray();
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs
new file mode 100644
index 00000000..de8d0f02
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModScanning
+{
+ /// <summary>Scans folders for mod data.</summary>
+ public class ModScanner
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The JSON helper with which to read manifests.</summary>
+ private readonly JsonHelper JsonHelper;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
+ public ModScanner(JsonHelper jsonHelper)
+ {
+ this.JsonHelper = jsonHelper;
+ }
+
+ /// <summary>Extract information about all mods in the given folder.</summary>
+ /// <param name="rootPath">The root folder containing mods.</param>
+ public IEnumerable<ModFolder> GetModFolders(string rootPath)
+ {
+ foreach (DirectoryInfo folder in new DirectoryInfo(rootPath).EnumerateDirectories())
+ yield return this.ReadFolder(rootPath, folder);
+ }
+
+ /// <summary>Extract information from a mod folder.</summary>
+ /// <param name="rootPath">The root folder containing mods.</param>
+ /// <param name="searchFolder">The folder to search for a mod.</param>
+ public ModFolder ReadFolder(string rootPath, DirectoryInfo searchFolder)
+ {
+ // find manifest.json
+ FileInfo manifestFile = this.FindManifest(searchFolder);
+ if (manifestFile == null)
+ return new ModFolder(searchFolder, null, null, "it doesn't have a manifest.");
+
+ // read mod info
+ Manifest manifest = null;
+ string manifestError = null;
+ {
+ try
+ {
+ manifest = this.JsonHelper.ReadJsonFile<Manifest>(manifestFile.FullName);
+ if (manifest == null)
+ manifestError = "its manifest is invalid.";
+ }
+ catch (SParseException ex)
+ {
+ manifestError = $"parsing its manifest failed: {ex.Message}";
+ }
+ catch (Exception ex)
+ {
+ manifestError = $"parsing its manifest failed:\n{ex}";
+ }
+ }
+
+ return new ModFolder(searchFolder, manifestFile.Directory, manifest, manifestError);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Find the manifest for a mod folder.</summary>
+ /// <param name="folder">The folder to search.</param>
+ private FileInfo FindManifest(DirectoryInfo folder)
+ {
+ while (true)
+ {
+ // check for manifest in current folder
+ FileInfo file = new FileInfo(Path.Combine(folder.FullName, "manifest.json"));
+ if (file.Exists)
+ return file;
+
+ // check for single subfolder
+ FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray();
+ if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder)
+ {
+ folder = subfolder;
+ continue;
+ }
+
+ // not found
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/ModToolkit.cs b/src/StardewModdingAPI.Toolkit/ModToolkit.cs
new file mode 100644
index 00000000..8c78b2f3
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/ModToolkit.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+using StardewModdingAPI.Toolkit.Framework.ModScanning;
+using StardewModdingAPI.Toolkit.Serialisation;
+
+namespace StardewModdingAPI.Toolkit
+{
+ /// <summary>A convenience wrapper for the various tools.</summary>
+ public class ModToolkit
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The default HTTP user agent for the toolkit.</summary>
+ private readonly string UserAgent;
+
+ /// <summary>Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID). This doesn't affect update checks, which defer to the remote web API.</summary>
+ private readonly IDictionary<string, string> VendorModUrls = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ["Chucklefish"] = "https://community.playstarbound.com/resources/{0}",
+ ["GitHub"] = "https://github.com/{0}/releases",
+ ["Nexus"] = "https://www.nexusmods.com/stardewvalley/mods/{0}"
+ };
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Encapsulates SMAPI's JSON parsing.</summary>
+ public JsonHelper JsonHelper { get; } = new JsonHelper();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModToolkit()
+ {
+ ISemanticVersion version = new SemanticVersion(this.GetType().Assembly.GetName().Version);
+ this.UserAgent = $"SMAPI Mod Handler Toolkit/{version}";
+ }
+
+ /// <summary>Extract mod metadata from the wiki compatibility list.</summary>
+ public async Task<WikiCompatibilityEntry[]> GetWikiCompatibilityListAsync()
+ {
+ var client = new WikiCompatibilityClient(this.UserAgent);
+ return await client.FetchAsync();
+ }
+
+ /// <summary>Get SMAPI's internal mod database.</summary>
+ /// <param name="metadataPath">The file path for the SMAPI metadata file.</param>
+ public ModDatabase GetModDatabase(string metadataPath)
+ {
+ MetadataModel metadata = JsonConvert.DeserializeObject<MetadataModel>(File.ReadAllText(metadataPath));
+ ModDataRecord[] records = metadata.ModData.Select(pair => new ModDataRecord(pair.Key, pair.Value)).ToArray();
+ return new ModDatabase(records, this.GetUpdateUrl);
+ }
+
+ /// <summary>Extract information about all mods in the given folder.</summary>
+ /// <param name="rootPath">The root folder containing mods.</param>
+ public IEnumerable<ModFolder> GetModFolders(string rootPath)
+ {
+ return new ModScanner(this.JsonHelper).GetModFolders(rootPath);
+ }
+
+ /// <summary>Get an update URL for an update key (if valid).</summary>
+ /// <param name="updateKey">The update key.</param>
+ public string GetUpdateUrl(string updateKey)
+ {
+ string[] parts = updateKey.Split(new[] { ':' }, 2);
+ if (parts.Length != 2)
+ return null;
+
+ string vendorKey = parts[0].Trim();
+ string modID = parts[1].Trim();
+
+ if (this.VendorModUrls.TryGetValue(vendorKey, out string urlTemplate))
+ return string.Format(urlTemplate, modID);
+
+ return null;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Properties/AssemblyInfo.cs b/src/StardewModdingAPI.Toolkit/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..1bb19e8c
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Properties/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+[assembly: AssemblyTitle("SMAPI.Toolkit")]
+[assembly: AssemblyDescription("A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.")]
+[assembly: InternalsVisibleTo("StardewModdingAPI")]
+[assembly: InternalsVisibleTo("StardewModdingAPI.Web")]
diff --git a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
new file mode 100644
index 00000000..156d58ce
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
@@ -0,0 +1,289 @@
+using System;
+using System.Text.RegularExpressions;
+
+namespace StardewModdingAPI.Toolkit
+{
+ /// <summary>A semantic version with an optional release tag.</summary>
+ /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks>
+ public class SemanticVersion : ISemanticVersion
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A regex pattern matching a valid prerelease tag.</summary>
+ internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+";
+
+ /// <summary>A regex pattern matching a version within a larger string.</summary>
+ internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?";
+
+ /// <summary>A regular expression matching a semantic version string.</summary>
+ /// <remarks>
+ /// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>,
+ /// with three important deviations intended to support Stardew Valley mod conventions:
+ /// - allows short-form "x.y" versions;
+ /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3");
+ /// - doesn't allow '+build' suffixes.
+ /// </remarks>
+ internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture);
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The major version incremented for major API changes.</summary>
+ public int MajorVersion { get; }
+
+ /// <summary>The minor version incremented for backwards-compatible changes.</summary>
+ public int MinorVersion { get; }
+
+ /// <summary>The patch version for backwards-compatible bug fixes.</summary>
+ public int PatchVersion { get; }
+
+ /// <summary>An optional prerelease tag.</summary>
+ public string Build { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="major">The major version incremented for major API changes.</param>
+ /// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
+ /// <param name="patch">The patch version for backwards-compatible fixes.</param>
+ /// <param name="tag">An optional prerelease tag.</param>
+ public SemanticVersion(int major, int minor, int patch, string tag = null)
+ {
+ this.MajorVersion = major;
+ this.MinorVersion = minor;
+ this.PatchVersion = patch;
+ this.Build = this.GetNormalisedTag(tag);
+
+ this.AssertValid();
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The assembly version.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
+ public SemanticVersion(Version version)
+ {
+ if (version == null)
+ throw new ArgumentNullException(nameof(version), "The input version can't be null.");
+
+ this.MajorVersion = version.Major;
+ this.MinorVersion = version.Minor;
+ this.PatchVersion = version.Build;
+
+ this.AssertValid();
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The semantic version string.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
+ /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
+ public SemanticVersion(string version)
+ {
+ // parse
+ if (version == null)
+ throw new ArgumentNullException(nameof(version), "The input version string can't be null.");
+ var match = SemanticVersion.Regex.Match(version.Trim());
+ if (!match.Success)
+ throw new FormatException($"The input '{version}' isn't a valid semantic version.");
+
+ // initialise
+ this.MajorVersion = int.Parse(match.Groups["major"].Value);
+ this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
+ this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
+ this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
+
+ this.AssertValid();
+ }
+
+ /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
+ /// <param name="other">The version to compare with this instance.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception>
+ public int CompareTo(ISemanticVersion other)
+ {
+ if (other == null)
+ throw new ArgumentNullException(nameof(other));
+ return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.Build);
+ }
+
+ /// <summary>Indicates whether the current object is equal to another object of the same type.</summary>
+ /// <returns>true if the current object is equal to the <paramref name="other" /> parameter; otherwise, false.</returns>
+ /// <param name="other">An object to compare with this object.</param>
+ public bool Equals(ISemanticVersion other)
+ {
+ return other != null && this.CompareTo(other) == 0;
+ }
+
+ /// <summary>Whether this is a pre-release version.</summary>
+ public bool IsPrerelease()
+ {
+ return !string.IsNullOrWhiteSpace(this.Build);
+ }
+
+ /// <summary>Get whether this version is older than the specified version.</summary>
+ /// <param name="other">The version to compare with this instance.</param>
+ public bool IsOlderThan(ISemanticVersion other)
+ {
+ return this.CompareTo(other) < 0;
+ }
+
+ /// <summary>Get whether this version is older than the specified version.</summary>
+ /// <param name="other">The version to compare with this instance.</param>
+ /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
+ public bool IsOlderThan(string other)
+ {
+ return this.IsOlderThan(new SemanticVersion(other));
+ }
+
+ /// <summary>Get whether this version is newer than the specified version.</summary>
+ /// <param name="other">The version to compare with this instance.</param>
+ public bool IsNewerThan(ISemanticVersion other)
+ {
+ return this.CompareTo(other) > 0;
+ }
+
+ /// <summary>Get whether this version is newer than the specified version.</summary>
+ /// <param name="other">The version to compare with this instance.</param>
+ /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
+ public bool IsNewerThan(string other)
+ {
+ return this.IsNewerThan(new SemanticVersion(other));
+ }
+
+ /// <summary>Get whether this version is between two specified versions (inclusively).</summary>
+ /// <param name="min">The minimum version.</param>
+ /// <param name="max">The maximum version.</param>
+ public bool IsBetween(ISemanticVersion min, ISemanticVersion max)
+ {
+ return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0;
+ }
+
+ /// <summary>Get whether this version is between two specified versions (inclusively).</summary>
+ /// <param name="min">The minimum version.</param>
+ /// <param name="max">The maximum version.</param>
+ /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception>
+ public bool IsBetween(string min, string max)
+ {
+ return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max));
+ }
+
+ /// <summary>Get a string representation of the version.</summary>
+ public override string ToString()
+ {
+ // version
+ string result = this.PatchVersion != 0
+ ? $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"
+ : $"{this.MajorVersion}.{this.MinorVersion}";
+
+ // tag
+ string tag = this.Build;
+ if (tag != null)
+ result += $"-{tag}";
+ return result;
+ }
+
+ /// <summary>Parse a version string without throwing an exception if it fails.</summary>
+ /// <param name="version">The version string.</param>
+ /// <param name="parsed">The parsed representation.</param>
+ /// <returns>Returns whether parsing the version succeeded.</returns>
+ public static bool TryParse(string version, out ISemanticVersion parsed)
+ {
+ try
+ {
+ parsed = new SemanticVersion(version);
+ return true;
+ }
+ catch
+ {
+ parsed = null;
+ return false;
+ }
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get a normalised build tag.</summary>
+ /// <param name="tag">The tag to normalise.</param>
+ private string GetNormalisedTag(string tag)
+ {
+ tag = tag?.Trim();
+ return !string.IsNullOrWhiteSpace(tag) ? tag : null;
+ }
+
+ /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
+ /// <param name="otherMajor">The major version to compare with this instance.</param>
+ /// <param name="otherMinor">The minor version to compare with this instance.</param>
+ /// <param name="otherPatch">The patch version to compare with this instance.</param>
+ /// <param name="otherTag">The prerelease tag to compare with this instance.</param>
+ private int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag)
+ {
+ const int same = 0;
+ const int curNewer = 1;
+ const int curOlder = -1;
+
+ // compare stable versions
+ if (this.MajorVersion != otherMajor)
+ return this.MajorVersion.CompareTo(otherMajor);
+ if (this.MinorVersion != otherMinor)
+ return this.MinorVersion.CompareTo(otherMinor);
+ if (this.PatchVersion != otherPatch)
+ return this.PatchVersion.CompareTo(otherPatch);
+ if (this.Build == otherTag)
+ return same;
+
+ // stable supercedes pre-release
+ bool curIsStable = string.IsNullOrWhiteSpace(this.Build);
+ bool otherIsStable = string.IsNullOrWhiteSpace(otherTag);
+ if (curIsStable)
+ return curNewer;
+ if (otherIsStable)
+ return curOlder;
+
+ // compare two pre-release tag values
+ string[] curParts = this.Build.Split('.', '-');
+ string[] otherParts = otherTag.Split('.', '-');
+ for (int i = 0; i < curParts.Length; i++)
+ {
+ // longer prerelease tag supercedes if otherwise equal
+ if (otherParts.Length <= i)
+ return curNewer;
+
+ // compare if different
+ if (curParts[i] != otherParts[i])
+ {
+ // compare numerically if possible
+ {
+ if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum))
+ return curNum.CompareTo(otherNum);
+ }
+
+ // else compare lexically
+ return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ // fallback (this should never happen)
+ return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ /// <summary>Assert that the current version is valid.</summary>
+ private void AssertValid()
+ {
+ if (this.MajorVersion < 0 || this.MinorVersion < 0 || this.PatchVersion < 0)
+ throw new FormatException($"{this} isn't a valid semantic version. The major, minor, and patch numbers can't be negative.");
+ if (this.MajorVersion == 0 && this.MinorVersion == 0 && this.PatchVersion == 0)
+ throw new FormatException($"{this} isn't a valid semantic version. At least one of the major, minor, and patch numbers must be more than zero.");
+ if (this.Build != null)
+ {
+ if (this.Build.Trim() == "")
+ throw new FormatException($"{this} isn't a valid semantic version. The tag cannot be a blank string (but may be omitted).");
+ if (!Regex.IsMatch(this.Build, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase))
+ throw new FormatException($"{this} isn't a valid semantic version. The tag is invalid.");
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs
index af7558f6..232c22a7 100644
--- a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs
@@ -1,11 +1,11 @@
using System;
using Newtonsoft.Json;
-using StardewModdingAPI.Framework.Models;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
-namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
{
- /// <summary>Handles deserialisation of <see cref="IManifestContentPackFor"/> arrays.</summary>
- internal class ManifestContentPackForConverter : JsonConverter
+ /// <summary>Handles deserialisation of <see cref="ManifestContentPackFor"/> arrays.</summary>
+ public class ManifestContentPackForConverter : JsonConverter
{
/*********
** Accessors
@@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
/// <param name="objectType">The object type.</param>
public override bool CanConvert(Type objectType)
{
- return objectType == typeof(IManifestContentPackFor[]);
+ return objectType == typeof(ManifestContentPackFor[]);
}
diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs
index 4150d5fb..0a304ee3 100644
--- a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestDependencyArrayConverter.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs
@@ -2,11 +2,11 @@ using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
-using StardewModdingAPI.Framework.Models;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
-namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
{
- /// <summary>Handles deserialisation of <see cref="IManifestDependency"/> arrays.</summary>
+ /// <summary>Handles deserialisation of <see cref="ManifestDependency"/> arrays.</summary>
internal class ManifestDependencyArrayConverter : JsonConverter
{
/*********
@@ -23,7 +23,7 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
/// <param name="objectType">The object type.</param>
public override bool CanConvert(Type objectType)
{
- return objectType == typeof(IManifestDependency[]);
+ return objectType == typeof(ManifestDependency[]);
}
@@ -37,12 +37,12 @@ namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
/// <param name="serializer">The calling serializer.</param>
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
- List<IManifestDependency> result = new List<IManifestDependency>();
+ List<ManifestDependency> result = new List<ManifestDependency>();
foreach (JObject obj in JArray.Load(reader).Children<JObject>())
{
- string uniqueID = obj.ValueIgnoreCase<string>(nameof(IManifestDependency.UniqueID));
- string minVersion = obj.ValueIgnoreCase<string>(nameof(IManifestDependency.MinimumVersion));
- bool required = obj.ValueIgnoreCase<bool?>(nameof(IManifestDependency.IsRequired)) ?? true;
+ string uniqueID = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.UniqueID));
+ string minVersion = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.MinimumVersion));
+ bool required = obj.ValueIgnoreCase<bool?>(nameof(ManifestDependency.IsRequired)) ?? true;
result.Add(new ManifestDependency(uniqueID, minVersion, required));
}
return result.ToArray();
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
new file mode 100644
index 00000000..9b2f5e7d
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
@@ -0,0 +1,88 @@
+using System;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
+{
+ /// <summary>Handles deserialisation of <see cref="ISemanticVersion"/>.</summary>
+ internal class SemanticVersionConverter : JsonConverter
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Get whether this converter can read JSON.</summary>
+ public override bool CanRead => true;
+
+ /// <summary>Get whether this converter can write JSON.</summary>
+ public override bool CanWrite => true;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether this instance can convert the specified object type.</summary>
+ /// <param name="objectType">The object type.</param>
+ public override bool CanConvert(Type objectType)
+ {
+ return typeof(ISemanticVersion).IsAssignableFrom(objectType);
+ }
+
+ /// <summary>Reads the JSON representation of the object.</summary>
+ /// <param name="reader">The JSON reader.</param>
+ /// <param name="objectType">The object type.</param>
+ /// <param name="existingValue">The object being read.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ string path = reader.Path;
+ switch (reader.TokenType)
+ {
+ case JsonToken.StartObject:
+ return this.ReadObject(JObject.Load(reader));
+ case JsonToken.String:
+ return this.ReadString(JToken.Load(reader).Value<string>(), path);
+ default:
+ throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path}).");
+ }
+ }
+
+ /// <summary>Writes the JSON representation of the object.</summary>
+ /// <param name="writer">The JSON writer.</param>
+ /// <param name="value">The value.</param>
+ /// <param name="serializer">The calling serializer.</param>
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ {
+ writer.WriteValue(value?.ToString());
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Read a JSON object.</summary>
+ /// <param name="obj">The JSON object to read.</param>
+ private ISemanticVersion ReadObject(JObject obj)
+ {
+ int major = obj.ValueIgnoreCase<int>("MajorVersion");
+ int minor = obj.ValueIgnoreCase<int>("MinorVersion");
+ int patch = obj.ValueIgnoreCase<int>("PatchVersion");
+ string build = obj.ValueIgnoreCase<string>("Build");
+ if (build == "0")
+ build = null; // '0' from incorrect examples in old SMAPI documentation
+
+ return new SemanticVersion(major, minor, patch, build);
+ }
+
+ /// <summary>Read a JSON string.</summary>
+ /// <param name="str">The JSON string value.</param>
+ /// <param name="path">The path to the current JSON node.</param>
+ private ISemanticVersion ReadString(string str, string path)
+ {
+ if (string.IsNullOrWhiteSpace(str))
+ return null;
+ if (!SemanticVersion.TryParse(str, out ISemanticVersion version))
+ throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path}).");
+ return (SemanticVersion)version;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Serialisation/SimpleReadOnlyConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs
index 5765ad96..5e0b0f4a 100644
--- a/src/SMAPI/Framework/Serialisation/SimpleReadOnlyConverter.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs
@@ -1,9 +1,8 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
-using StardewModdingAPI.Framework.Exceptions;
-namespace StardewModdingAPI.Framework.Serialisation
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
{
/// <summary>The base implementation for simplified converters which deserialise <typeparamref name="T"/> without overriding serialisation.</summary>
/// <typeparam name="T">The type to deserialise.</typeparam>
diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/StringEnumConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs
index c88ac834..13e6e3a1 100644
--- a/src/SMAPI/Framework/Serialisation/SmapiConverters/StringEnumConverter.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs
@@ -1,7 +1,7 @@
using System;
using Newtonsoft.Json.Converters;
-namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters
+namespace StardewModdingAPI.Toolkit.Serialisation.Converters
{
/// <summary>A variant of <see cref="StringEnumConverter"/> which only converts a specified enum.</summary>
/// <typeparam name="T">The enum type.</typeparam>
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs b/src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs
new file mode 100644
index 00000000..12b2c933
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/InternalExtensions.cs
@@ -0,0 +1,21 @@
+using System;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Toolkit.Serialisation
+{
+ /// <summary>Provides extension methods for parsing JSON.</summary>
+ public static class JsonExtensions
+ {
+ /// <summary>Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="obj">The JSON object to search.</param>
+ /// <param name="fieldName">The field name.</param>
+ public static T ValueIgnoreCase<T>(this JObject obj, string fieldName)
+ {
+ JToken token = obj.GetValue(fieldName, StringComparison.InvariantCultureIgnoreCase);
+ return token != null
+ ? token.Value<T>()
+ : default(T);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Serialisation/JsonHelper.cs b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
index 6cba343e..00f334ad 100644
--- a/src/SMAPI/Framework/Serialisation/JsonHelper.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
@@ -1,39 +1,23 @@
using System;
using System.Collections.Generic;
using System.IO;
-using Microsoft.Xna.Framework.Input;
using Newtonsoft.Json;
-using StardewModdingAPI.Framework.Serialisation.CrossplatformConverters;
-using StardewModdingAPI.Framework.Serialisation.SmapiConverters;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
-namespace StardewModdingAPI.Framework.Serialisation
+namespace StardewModdingAPI.Toolkit.Serialisation
{
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
- internal class JsonHelper
+ public class JsonHelper
{
/*********
** Accessors
*********/
/// <summary>The JSON settings to use when serialising and deserialising files.</summary>
- private readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
+ public JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded
- Converters = new List<JsonConverter>
- {
- // SMAPI types
- new SemanticVersionConverter(),
-
- // enums
- new StringEnumConverter<Buttons>(),
- new StringEnumConverter<Keys>(),
- new StringEnumConverter<SButton>(),
-
- // crossplatform compatibility
- new ColorConverter(),
- new PointConverter(),
- new RectangleConverter()
- }
+ Converters = new List<JsonConverter> { new SemanticVersionConverter() }
};
diff --git a/src/SMAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs
index f5867cf3..6cb9496b 100644
--- a/src/SMAPI/Framework/Models/Manifest.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Models/Manifest.cs
@@ -1,11 +1,11 @@
using System.Collections.Generic;
using Newtonsoft.Json;
-using StardewModdingAPI.Framework.Serialisation.SmapiConverters;
+using StardewModdingAPI.Toolkit.Serialisation.Converters;
-namespace StardewModdingAPI.Framework.Models
+namespace StardewModdingAPI.Toolkit.Serialisation.Models
{
/// <summary>A manifest which describes a mod for SMAPI.</summary>
- internal class Manifest : IManifest
+ public class Manifest : IManifest
{
/*********
** Accessors
@@ -25,10 +25,10 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>The minimum SMAPI version required by this mod, if any.</summary>
public ISemanticVersion MinimumApiVersion { get; set; }
- /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary>
+ /// <summary>The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary>
public string EntryDll { get; set; }
- /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="IManifest.EntryDll"/>.</summary>
+ /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="Manifest.EntryDll"/>.</summary>
[JsonConverter(typeof(ManifestContentPackForConverter))]
public IManifestContentPackFor ContentPackFor { get; set; }
@@ -45,5 +45,30 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Any manifest fields which didn't match a valid field.</summary>
[JsonExtensionData]
public IDictionary<string, object> ExtraFields { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public Manifest() { }
+
+ /// <summary>Construct an instance for a transitional content pack.</summary>
+ /// <param name="uniqueID">The unique mod ID.</param>
+ /// <param name="name">The mod name.</param>
+ /// <param name="author">The mod author's name.</param>
+ /// <param name="description">A brief description of the mod.</param>
+ /// <param name="version">The mod version.</param>
+ /// <param name="contentPackFor">The modID which will read this as a content pack.</param>
+ public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string contentPackFor = null)
+ {
+ this.Name = name;
+ this.Author = author;
+ this.Description = description;
+ this.Version = version;
+ this.UniqueID = uniqueID;
+ this.UpdateKeys = new string[0];
+ this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor };
+ }
}
}
diff --git a/src/SMAPI/Framework/Models/ManifestContentPackFor.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs
index 7836bbcc..d0e42216 100644
--- a/src/SMAPI/Framework/Models/ManifestContentPackFor.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs
@@ -1,7 +1,7 @@
-namespace StardewModdingAPI.Framework.Models
+namespace StardewModdingAPI.Toolkit.Serialisation.Models
{
/// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary>
- internal class ManifestContentPackFor : IManifestContentPackFor
+ public class ManifestContentPackFor : IManifestContentPackFor
{
/*********
** Accessors
diff --git a/src/SMAPI/Framework/Models/ManifestDependency.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs
index 97f0775a..8db58d5d 100644
--- a/src/SMAPI/Framework/Models/ManifestDependency.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Models/ManifestDependency.cs
@@ -1,7 +1,7 @@
-namespace StardewModdingAPI.Framework.Models
+namespace StardewModdingAPI.Toolkit.Serialisation.Models
{
/// <summary>A mod dependency listed in a mod manifest.</summary>
- internal class ManifestDependency : IManifestDependency
+ public class ManifestDependency : IManifestDependency
{
/*********
** Accessors
diff --git a/src/SMAPI/Framework/Exceptions/SParseException.cs b/src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs
index f7133ee7..61a7b305 100644
--- a/src/SMAPI/Framework/Exceptions/SParseException.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/SParseException.cs
@@ -1,6 +1,6 @@
-using System;
+using System;
-namespace StardewModdingAPI.Framework.Exceptions
+namespace StardewModdingAPI.Toolkit.Serialisation
{
/// <summary>A format exception which provides a user-facing error message.</summary>
internal class SParseException : FormatException
diff --git a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
new file mode 100644
index 00000000..21c130b3
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit</OutputPath>
+ <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit\$(TargetFramework)\StardewModdingAPI.Toolkit.xml</DocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="HtmlAgilityPack" Version="1.8.4" />
+ <PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
+ <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj" />
+ </ItemGroup>
+
+ <Import Project="..\..\build\common.targets" />
+
+</Project>
diff --git a/src/StardewModdingAPI.Toolkit/Utilities/FileUtilities.cs b/src/StardewModdingAPI.Toolkit/Utilities/FileUtilities.cs
new file mode 100644
index 00000000..7856fdb1
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Utilities/FileUtilities.cs
@@ -0,0 +1,46 @@
+using System.IO;
+using System.Threading;
+
+namespace StardewModdingAPI.Toolkit.Utilities
+{
+ /// <summary>Provides utilities for dealing with files.</summary>
+ public static class FileUtilities
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary>
+ /// <param name="entry">The file or folder to reset.</param>
+ public static void ForceDelete(FileSystemInfo entry)
+ {
+ // ignore if already deleted
+ entry.Refresh();
+ if (!entry.Exists)
+ return;
+
+ // delete children
+ if (entry is DirectoryInfo folder)
+ {
+ foreach (FileSystemInfo child in folder.GetFileSystemInfos())
+ FileUtilities.ForceDelete(child);
+ }
+
+ // reset permissions & delete
+ entry.Attributes = FileAttributes.Normal;
+ entry.Delete();
+
+ // wait for deletion to finish
+ for (int i = 0; i < 10; i++)
+ {
+ entry.Refresh();
+ if (entry.Exists)
+ Thread.Sleep(500);
+ }
+
+ // throw exception if deletion didn't happen before timeout
+ entry.Refresh();
+ if (entry.Exists)
+ throw new IOException($"Timed out trying to delete {entry.FullName}");
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Utilities/PathUtilities.cs b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs
index 0233d796..2e74e7d9 100644
--- a/src/SMAPI/Framework/Utilities/PathUtilities.cs
+++ b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs
@@ -3,10 +3,10 @@ using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
-namespace StardewModdingAPI.Framework.Utilities
+namespace StardewModdingAPI.Toolkit.Utilities
{
/// <summary>Provides utilities for normalising file paths.</summary>
- internal static class PathUtilities
+ public static class PathUtilities
{
/*********
** Properties
@@ -23,9 +23,12 @@ namespace StardewModdingAPI.Framework.Utilities
*********/
/// <summary>Get the segments from a path (e.g. <c>/usr/bin/boop</c> => <c>usr</c>, <c>bin</c>, and <c>boop</c>).</summary>
/// <param name="path">The path to split.</param>
- public static string[] GetSegments(string path)
+ /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param>
+ public static string[] GetSegments(string path, int? limit = null)
{
- return path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
+ return limit.HasValue
+ ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries)
+ : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>Normalise path separators in a file path.</summary>
diff --git a/src/lib/0Harmony.dll b/src/lib/0Harmony.dll
new file mode 100644
index 00000000..63619429
--- /dev/null
+++ b/src/lib/0Harmony.dll
Binary files differ
diff --git a/src/lib/0Harmony.pdb b/src/lib/0Harmony.pdb
new file mode 100644
index 00000000..d7a4c67c
--- /dev/null
+++ b/src/lib/0Harmony.pdb
Binary files differ