Add game version management system with manifest support

Features:
- Version dropdown in single/ZIP upload (9.3.0, 9.2.0, etc.)
- Patch-compatible matching (9.3.x assets work with 9.3.0)
- manifest.json/xml support for automatic metadata detection
- Smart category auto-detection from folder structure
- Version field stored in GameAssets table

Manifest support:
- JSON format with gameVersion, category, assets array
- Per-file metadata overrides (type, required, description)
- Auto-detect falls back if no manifest present
- See ASSET-MANIFEST-SPECIFICATION.md for full spec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-20 09:55:05 -08:00
parent f289cdfce9
commit dd2c23000f
21 changed files with 1409 additions and 32 deletions

View File

@@ -0,0 +1,154 @@
# RR3 Asset Manifest Specification
## Overview
When uploading ZIP files to the RR3 Community Server, you can include a `manifest.json` or `manifest.xml` file to automatically configure asset metadata, version, and categorization.
## File Format Options
### Option 1: JSON Format (Recommended)
Place `manifest.json` in the root of your ZIP file:
```json
{
"version": "9.3.0",
"gameVersion": "9.3.0",
"description": "Porsche Pack - 911 Models",
"author": "CommunityModder",
"category": "cars",
"assets": [
{
"file": "porsche/911_turbo.dat",
"category": "cars/porsche",
"type": "Model",
"required": true,
"description": "Porsche 911 Turbo model"
},
{
"file": "textures/911_turbo_paint.tex",
"category": "textures/cars",
"type": "Texture",
"required": false
}
]
}
```
### Option 2: XML Format
Place `manifest.xml` in the root of your ZIP file:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<AssetManifest>
<Version>9.3.0</Version>
<GameVersion>9.3.0</GameVersion>
<Description>Porsche Pack - 911 Models</Description>
<Author>CommunityModder</Author>
<Category>cars</Category>
<Assets>
<Asset>
<File>porsche/911_turbo.dat</File>
<Category>cars/porsche</Category>
<Type>Model</Type>
<Required>true</Required>
<Description>Porsche 911 Turbo model</Description>
</Asset>
<Asset>
<File>textures/911_turbo_paint.tex</File>
<Category>textures/cars</Category>
<Type>Texture</Type>
<Required>false</Required>
</Asset>
</Assets>
</AssetManifest>
```
## Field Descriptions
### Root Fields
- **version**: Asset pack version (e.g., "1.0.0")
- **gameVersion**: RR3 game version this pack is for (e.g., "9.3.0")
- **description**: Brief description of the asset pack
- **author**: Creator name (optional)
- **category**: Default category if not specified per-asset
### Asset Fields
- **file**: Relative path to file within ZIP (required)
- **category**: Asset category (overrides root category)
- **type**: Asset type (Data, Texture, Audio, Model, Config)
- **required**: Whether clients must download this (true/false)
- **description**: Brief description (optional)
## Game Version Format
- Use semantic versioning: `MAJOR.MINOR.PATCH`
- Examples: `9.3.0`, `9.3.1`, `10.0.0`
- **Compatibility**: Patch versions are compatible (9.3.x works with 9.3.0)
## Categories
Standard categories:
- `base` - Core game files
- `cars` - Car models and data
- `tracks` - Track models and data
- `audio` - Sound effects and music
- `textures` - Texture files
- `ui` - User interface elements
- `events` - Event configurations
- `dlc` - Downloadable content
- `updates` - Game updates
Subcategories allowed (e.g., `cars/porsche`, `tracks/silverstone`)
## Asset Types
- `Data` - Generic data files (.dat, .pak, .z)
- `Texture` - Texture files (.tex, .dds, .png)
- `Audio` - Audio files (.ogg, .mp3, .wav)
- `Model` - 3D model files (.nct, .obj)
- `Config` - Configuration files (.json, .xml)
## Automatic Detection Fallback
If no manifest file is provided, the server uses smart detection:
1. Searches folder names for keywords (cars, tracks, audio, etc.)
2. Preserves folder structure as subcategories
3. Falls back to first folder name if no keywords match
4. Version defaults to manual selection or "unknown"
## Example ZIP Structure
```
my-asset-pack.zip
├── manifest.json # Metadata file
├── cars/
│ ├── porsche/
│ │ ├── 911_turbo.dat
│ │ └── 911_gt3.dat
│ └── ferrari/
│ └── 488_gtb.dat
└── textures/
└── cars/
└── paint_textures.pak
```
## Upload Behavior
1. Server extracts ZIP to temp location
2. Searches for `manifest.json` or `manifest.xml` in root
3. If found: Uses metadata from manifest
4. If not found: Uses smart folder detection
5. Processes each file according to configuration
6. Calculates MD5/SHA256 hashes automatically
7. Stores in database with version + category info
## Version Compatibility
When a game client requests assets:
- Client sends version: `9.3.1`
- Server returns assets for: `9.3.0`, `9.3.1` (patch-compatible)
- Server excludes: `9.2.x`, `9.4.x`, `10.x.x`
Major/minor versions must match exactly, patch versions are compatible within the same minor version.
## Best Practices
1. Always include a manifest file for large packs
2. Use semantic versioning for both pack and game version
3. Mark base game assets as `required: true`
4. Use descriptive categories and subcategories
5. Include author information for community tracking
6. Test with single-file upload before bulk ZIP upload
7. Use JSON format for better tool support

View File

@@ -0,0 +1,966 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RR3CommunityServer.Data;
#nullable disable
namespace RR3CommunityServer.Migrations
{
[DbContext(typeof(RR3DbContext))]
[Migration("20260220175450_AddGameVersioning")]
partial class AddGameVersioning
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("RR3CommunityServer.Data.Car", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Available")
.HasColumnType("INTEGER");
b.Property<int>("BasePerformanceRating")
.HasColumnType("INTEGER");
b.Property<string>("CarId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CashPrice")
.HasColumnType("INTEGER");
b.Property<string>("ClassType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomAuthor")
.HasColumnType("TEXT");
b.Property<string>("CustomVersion")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<int>("GoldPrice")
.HasColumnType("INTEGER");
b.Property<bool>("IsCustom")
.HasColumnType("INTEGER");
b.Property<string>("Manufacturer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Cars");
b.HasData(
new
{
Id = 1,
Available = true,
BasePerformanceRating = 45,
CarId = "nissan_silvia_s15",
CashPrice = 25000,
ClassType = "C",
GoldPrice = 0,
IsCustom = false,
Manufacturer = "Nissan",
Name = "Nissan Silvia Spec-R",
Year = 0
},
new
{
Id = 2,
Available = true,
BasePerformanceRating = 58,
CarId = "ford_focus_rs",
CashPrice = 85000,
ClassType = "B",
GoldPrice = 150,
IsCustom = false,
Manufacturer = "Ford",
Name = "Ford Focus RS",
Year = 0
},
new
{
Id = 3,
Available = true,
BasePerformanceRating = 72,
CarId = "porsche_911_gt3",
CashPrice = 0,
ClassType = "A",
GoldPrice = 350,
IsCustom = false,
Manufacturer = "Porsche",
Name = "Porsche 911 GT3 RS",
Year = 0
},
new
{
Id = 4,
Available = true,
BasePerformanceRating = 88,
CarId = "ferrari_488_gtb",
CashPrice = 0,
ClassType = "S",
GoldPrice = 750,
IsCustom = false,
Manufacturer = "Ferrari",
Name = "Ferrari 488 GTB",
Year = 0
},
new
{
Id = 5,
Available = true,
BasePerformanceRating = 105,
CarId = "mclaren_p1_gtr",
CashPrice = 0,
ClassType = "R",
GoldPrice = 1500,
IsCustom = false,
Manufacturer = "McLaren",
Name = "McLaren P1 GTR",
Year = 0
});
});
modelBuilder.Entity("RR3CommunityServer.Data.CarUpgrade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CarId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CashCost")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("PerformanceIncrease")
.HasColumnType("INTEGER");
b.Property<string>("UpgradeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CarUpgrades");
b.HasData(
new
{
Id = 1,
CarId = "nissan_silvia_s15",
CashCost = 5000,
Level = 1,
PerformanceIncrease = 3,
UpgradeType = "engine"
},
new
{
Id = 2,
CarId = "nissan_silvia_s15",
CashCost = 3000,
Level = 1,
PerformanceIncrease = 2,
UpgradeType = "tires"
},
new
{
Id = 3,
CarId = "nissan_silvia_s15",
CashCost = 4000,
Level = 1,
PerformanceIncrease = 2,
UpgradeType = "suspension"
},
new
{
Id = 4,
CarId = "nissan_silvia_s15",
CashCost = 3500,
Level = 1,
PerformanceIncrease = 2,
UpgradeType = "brakes"
},
new
{
Id = 5,
CarId = "nissan_silvia_s15",
CashCost = 4500,
Level = 1,
PerformanceIncrease = 3,
UpgradeType = "drivetrain"
});
});
modelBuilder.Entity("RR3CommunityServer.Data.CareerProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("BestTime")
.HasColumnType("REAL");
b.Property<bool>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("EventName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SeriesName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("StarsEarned")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("CareerProgress");
});
modelBuilder.Entity("RR3CommunityServer.Data.CatalogItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Available")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Price")
.HasColumnType("TEXT");
b.Property<string>("Sku")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CatalogItems");
b.HasData(
new
{
Id = 1,
Available = true,
Name = "1000 Gold",
Price = 0.99m,
Sku = "com.ea.rr3.gold_1000",
Type = "currency"
},
new
{
Id = 2,
Available = true,
Name = "Starter Car",
Price = 0m,
Sku = "com.ea.rr3.car_tier1",
Type = "car"
},
new
{
Id = 3,
Available = true,
Name = "Engine Upgrade",
Price = 4.99m,
Sku = "com.ea.rr3.upgrade_engine",
Type = "upgrade"
},
new
{
Id = 4,
Available = true,
Name = "100 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_100",
Type = "currency"
},
new
{
Id = 5,
Available = true,
Name = "500 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_500",
Type = "currency"
},
new
{
Id = 6,
Available = true,
Name = "1000 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_1000",
Type = "currency"
},
new
{
Id = 7,
Available = true,
Name = "5000 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_5000",
Type = "currency"
});
});
modelBuilder.Entity("RR3CommunityServer.Data.DailyReward", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CashAmount")
.HasColumnType("INTEGER");
b.Property<bool>("Claimed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClaimedAt")
.HasColumnType("TEXT");
b.Property<int>("GoldAmount")
.HasColumnType("INTEGER");
b.Property<DateTime>("RewardDate")
.HasColumnType("TEXT");
b.Property<int>("Streak")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("DailyRewards");
});
modelBuilder.Entity("RR3CommunityServer.Data.Device", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HardwareId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Devices");
});
modelBuilder.Entity("RR3CommunityServer.Data.GameAsset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessCount")
.HasColumnType("INTEGER");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssetType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CarId")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long?>("CompressedSize")
.HasColumnType("INTEGER");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CustomAuthor")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<DateTime>("DownloadedAt")
.HasColumnType("TEXT");
b.Property<string>("EaCdnPath")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileSha256")
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<bool>("IsAvailable")
.HasColumnType("INTEGER");
b.Property<bool>("IsCustomContent")
.HasColumnType("INTEGER");
b.Property<bool>("IsRequired")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastAccessedAt")
.HasColumnType("TEXT");
b.Property<string>("LocalPath")
.HasColumnType("TEXT");
b.Property<string>("Md5Hash")
.HasColumnType("TEXT");
b.Property<string>("OriginalUrl")
.HasColumnType("TEXT");
b.Property<string>("TrackId")
.HasColumnType("TEXT");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("Version")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("GameAssets");
});
modelBuilder.Entity("RR3CommunityServer.Data.ModPack", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Author")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CarIds")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<int>("DownloadCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PackId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<double>("Rating")
.HasColumnType("REAL");
b.Property<string>("TrackIds")
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ModPacks");
});
modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CarId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CarName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ClassType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Manufacturer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PerformanceRating")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<string>("PurchasedUpgrades")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UpgradeLevel")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("OwnedCars");
});
modelBuilder.Entity("RR3CommunityServer.Data.Purchase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OrderId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Price")
.HasColumnType("TEXT");
b.Property<DateTime>("PurchaseTime")
.HasColumnType("TEXT");
b.Property<string>("Sku")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SynergyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Purchases");
});
modelBuilder.Entity("RR3CommunityServer.Data.Session", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SynergyId")
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Sessions");
});
modelBuilder.Entity("RR3CommunityServer.Data.TimeTrial", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("CarName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CashReward")
.HasColumnType("INTEGER");
b.Property<DateTime>("EndDate")
.HasColumnType("TEXT");
b.Property<int>("GoldReward")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<double>("TargetTime")
.HasColumnType("REAL");
b.Property<string>("TrackName")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("TimeTrials");
b.HasData(
new
{
Id = 1,
Active = true,
CarName = "Any Car",
CashReward = 10000,
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3387),
GoldReward = 50,
Name = "Daily Sprint Challenge",
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3384),
TargetTime = 90.5,
TrackName = "Silverstone National"
},
new
{
Id = 2,
Active = true,
CarName = "Any Car",
CashReward = 25000,
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
GoldReward = 100,
Name = "Speed Demon Trial",
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
TargetTime = 120.0,
TrackName = "Dubai Autodrome"
});
});
modelBuilder.Entity("RR3CommunityServer.Data.TimeTrialResult", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("BeatTarget")
.HasColumnType("INTEGER");
b.Property<int>("CashEarned")
.HasColumnType("INTEGER");
b.Property<int>("GoldEarned")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<double>("TimeSeconds")
.HasColumnType("REAL");
b.Property<int>("TimeTrialId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TimeTrialResults");
});
modelBuilder.Entity("RR3CommunityServer.Data.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Cash")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.HasColumnType("TEXT");
b.Property<int?>("Experience")
.HasColumnType("INTEGER");
b.Property<int?>("Gold")
.HasColumnType("INTEGER");
b.Property<int?>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Nickname")
.HasColumnType("TEXT");
b.Property<int?>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("SynergyId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("RR3CommunityServer.Models.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EmailVerificationToken")
.HasColumnType("TEXT");
b.Property<bool>("EmailVerified")
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("PasswordResetExpiry")
.HasColumnType("TEXT");
b.Property<string>("PasswordResetToken")
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("RR3CommunityServer.Models.DeviceAccount", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LinkedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("DeviceAccounts");
});
modelBuilder.Entity("RR3CommunityServer.Models.UserSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastUpdated")
.HasColumnType("TEXT");
b.Property<string>("Mode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserSettings");
});
modelBuilder.Entity("RR3CommunityServer.Data.CareerProgress", b =>
{
b.HasOne("RR3CommunityServer.Data.User", null)
.WithMany("CareerProgress")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b =>
{
b.HasOne("RR3CommunityServer.Data.User", null)
.WithMany("OwnedCars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RR3CommunityServer.Models.Account", b =>
{
b.HasOne("RR3CommunityServer.Data.User", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("RR3CommunityServer.Models.DeviceAccount", b =>
{
b.HasOne("RR3CommunityServer.Models.Account", "Account")
.WithMany("LinkedDevices")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
});
modelBuilder.Entity("RR3CommunityServer.Data.User", b =>
{
b.Navigation("CareerProgress");
b.Navigation("OwnedCars");
});
modelBuilder.Entity("RR3CommunityServer.Models.Account", b =>
{
b.Navigation("LinkedDevices");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RR3CommunityServer.Migrations
{
/// <inheritdoc />
public partial class AddGameVersioning : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3387), new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3384) });
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395), new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395) });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2221), new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2218) });
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228), new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228) });
}
}
}

View File

@@ -711,10 +711,10 @@ namespace RR3CommunityServer.Migrations
Active = true,
CarName = "Any Car",
CashReward = 10000,
EndDate = new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2221),
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3387),
GoldReward = 50,
Name = "Daily Sprint Challenge",
StartDate = new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2218),
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3384),
TargetTime = 90.5,
TrackName = "Silverstone National"
},
@@ -724,10 +724,10 @@ namespace RR3CommunityServer.Migrations
Active = true,
CarName = "Any Car",
CashReward = 25000,
EndDate = new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228),
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
GoldReward = 100,
Name = "Speed Demon Trial",
StartDate = new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228),
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
TargetTime = 120.0,
TrackName = "Dubai Autodrome"
});

View File

@@ -74,7 +74,12 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="zip-tab" data-bs-toggle="tab" data-bs-target="#zip" type="button" role="tab">
📦 ZIP Bulk Upload
📦 ZIP Upload
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="url-tab" data-bs-toggle="tab" data-bs-target="#url" type="button" role="tab">
🌐 URL Download
</button>
</li>
</ul>
@@ -132,7 +137,30 @@
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<label for="gameVersion" class="form-label">Game Version</label>
<select class="form-select" id="gameVersion" name="gameVersion" required>
<option value="">Select version...</option>
<option value="9.3.0">9.3.0</option>
<option value="9.2.0">9.2.0</option>
<option value="9.1.0">9.1.0</option>
<option value="9.0.0">9.0.0</option>
<option value="8.9.0">8.9.0</option>
<option value="universal">Universal (All Versions)</option>
</select>
<small class="text-muted">Patch-compatible: 9.3.x works with 9.3.0</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of this asset..."></textarea>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">Options</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequired" name="isRequired" checked>
<label class="form-check-label" for="isRequired">
@@ -142,10 +170,6 @@
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of this asset..."></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Asset
</button>
@@ -156,19 +180,35 @@
<div class="tab-pane fade" id="zip" role="tabpanel">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>ZIP Upload:</strong>
Folder structure preserved • Auto MD5 calculation • Existing assets updated
Folder structure preserved • Auto MD5 calculation • Manifest.json support
</div>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadZip">
<div class="mb-3">
<label for="zipFile" class="form-label">ZIP Archive</label>
<input class="form-control" type="file" id="zipFile" name="zipFile" accept=".zip" required>
<small class="text-muted">Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
<small class="text-muted">Include manifest.json for auto-detection • Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
</div>
<div class="row">
<div class="col-md-8">
<div class="col-md-4">
<div class="mb-3">
<label for="zipGameVersion" class="form-label">Game Version</label>
<select class="form-select" id="zipGameVersion" name="gameVersion" required>
<option value="">Detect from manifest...</option>
<option value="9.3.0">9.3.0 (Latest)</option>
<option value="9.2.0">9.2.0</option>
<option value="9.1.0">9.1.0</option>
<option value="9.0.0">9.0.0</option>
<option value="8.9.0">8.9.0</option>
<option value="universal">Universal</option>
</select>
<small class="text-muted">Or specify in manifest.json</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="baseCategory" class="form-label">Base Category</label>
<select class="form-select" id="baseCategory" name="baseCategory">
<option value="auto">🤖 Auto-detect</option>
<option value="base">Base Assets</option>
<option value="cars">Cars</option>
<option value="tracks">Tracks</option>
@@ -183,11 +223,11 @@
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<label class="form-label d-block">Options</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequiredZip" name="isRequired" checked>
<label class="form-check-label" for="isRequiredZip">
All required
Mark as required
</label>
</div>
</div>
@@ -198,6 +238,55 @@
</button>
</form>
</div>
<!-- URL Download Tab -->
<div class="tab-pane fade" id="url" role="tabpanel">
<div class="alert alert-success">
<i class="bi bi-cloud-arrow-down"></i> <strong>Direct Download:</strong>
Server downloads ZIP directly • No browser upload needed • Perfect for large files
</div>
<form method="post" asp-page-handler="DownloadZip">
<div class="mb-3">
<label for="zipUrl" class="form-label">ZIP File URL</label>
<input type="url" class="form-control" id="zipUrl" name="zipUrl"
placeholder="https://example.com/assets/rr3-cars-pack.zip" required>
<small class="text-muted">Direct link to ZIP file (http:// or https://)</small>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="baseCategoryUrl" class="form-label">Base Category (optional)</label>
<select class="form-select" id="baseCategoryUrl" name="baseCategory">
<option value="base">Auto-Detect (Smart)</option>
<option value="cars">Cars</option>
<option value="tracks">Tracks</option>
<option value="audio">Audio</option>
<option value="textures">Textures</option>
<option value="ui">UI</option>
<option value="events">Events</option>
<option value="dlc">DLC</option>
<option value="updates">Updates</option>
</select>
<small class="text-muted">System will auto-detect categories from folder names</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequiredUrl" name="isRequired" checked>
<label class="form-check-label" for="isRequiredUrl">
All required
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download"></i> Download and Extract
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using System.Security.Cryptography;
using System.IO.Compression;
using System.Text.Json;
namespace RR3CommunityServer.Pages;
@@ -46,6 +47,7 @@ public class AssetsModel : PageModel
string category,
string assetType,
bool isRequired,
string gameVersion,
string? description)
{
try
@@ -108,6 +110,7 @@ public class AssetsModel : PageModel
existingAsset.IsRequired = isRequired;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
Message = $"Asset '{fileName}' updated successfully!";
@@ -128,6 +131,7 @@ public class AssetsModel : PageModel
IsRequired = isRequired,
Description = description,
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -154,6 +158,7 @@ public class AssetsModel : PageModel
public async Task<IActionResult> OnPostUploadZipAsync(
IFormFile zipFile,
string baseCategory,
string? gameVersion,
bool isRequired)
{
try
@@ -191,6 +196,47 @@ public class AssetsModel : PageModel
int skippedCount = 0;
var errors = new List<string>();
// Try to parse manifest file if exists
AssetManifest? manifest = null;
using (var archive = ZipFile.OpenRead(tempZipPath))
{
var manifestEntry = archive.Entries.FirstOrDefault(e =>
e.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
e.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase));
if (manifestEntry != null)
{
try
{
using var stream = manifestEntry.Open();
using var reader = new StreamReader(stream);
var manifestContent = await reader.ReadToEndAsync();
if (manifestEntry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifest = JsonSerializer.Deserialize<AssetManifest>(manifestContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
if (manifest != null)
{
_logger.LogInformation("Loaded manifest: Version={Version}, GameVersion={GameVersion}",
manifest.Version, manifest.GameVersion);
// Override parameters from manifest if provided
if (!string.IsNullOrEmpty(manifest.GameVersion))
gameVersion = manifest.GameVersion;
if (!string.IsNullOrEmpty(manifest.Category) && baseCategory == "auto")
baseCategory = manifest.Category;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse manifest file, using defaults");
}
}
}
// Extract ZIP and process each file
using (var archive = ZipFile.OpenRead(tempZipPath))
{
@@ -198,18 +244,31 @@ public class AssetsModel : PageModel
{
try
{
// Skip directories
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/"))
// Skip directories and manifest files
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/") ||
entry.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
entry.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase))
continue;
// Determine category from path in ZIP
var pathParts = entry.FullName.Split('/', '\\');
var category = baseCategory;
// Check if this file has manifest metadata
ManifestAsset? manifestAsset = manifest?.Assets?.FirstOrDefault(a =>
a.File.Replace("\\", "/").Equals(entry.FullName.Replace("\\", "/"),
StringComparison.OrdinalIgnoreCase));
// If ZIP has folders, use first folder as subcategory
if (pathParts.Length > 1)
// Determine category from manifest or path in ZIP
var pathParts = entry.FullName.Split('/', '\\');
var category = manifestAsset?.Category ?? baseCategory;
// Auto-detect if category is "auto"
if (category == "auto")
{
category = Path.Combine(baseCategory, pathParts[0]);
category = SmartDetectCategory(entry.FullName) ?? "base";
}
// If ZIP has folders and no manifest, use first folder as subcategory
if (manifestAsset == null && pathParts.Length > 1 && category != "auto")
{
category = Path.Combine(category, pathParts[0]);
}
// Create category subdirectory
@@ -234,6 +293,11 @@ public class AssetsModel : PageModel
// Build EA CDN path from ZIP structure
var eaCdnPath = "/" + entry.FullName.Replace("\\", "/");
// Determine asset type from manifest or filename
var assetType = manifestAsset?.Type ?? DetermineAssetType(fileName);
var required = manifestAsset?.Required ?? isRequired;
var description = manifestAsset?.Description;
// Check if asset already exists
var existingAsset = await _context.GameAssets
.FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath);
@@ -247,8 +311,11 @@ public class AssetsModel : PageModel
existingAsset.Md5Hash = md5Hash;
existingAsset.FileSha256 = sha256Hash;
existingAsset.Category = category;
existingAsset.IsRequired = isRequired;
existingAsset.AssetType = assetType;
existingAsset.IsRequired = required;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
skippedCount++;
}
@@ -264,10 +331,11 @@ public class AssetsModel : PageModel
Md5Hash = md5Hash,
FileSha256 = sha256Hash,
Category = category,
AssetType = DetermineAssetType(fileName),
IsRequired = isRequired,
Description = $"Extracted from {zipFile.FileName}",
AssetType = assetType,
IsRequired = required,
Description = description ?? $"Extracted from {zipFile.FileName}",
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -446,6 +514,36 @@ public class AssetsModel : PageModel
};
}
private string? SmartDetectCategory(string fullPath)
{
var lowerPath = fullPath.ToLowerInvariant();
// Check for known keywords in path
if (lowerPath.Contains("car") || lowerPath.Contains("vehicle") || lowerPath.Contains("automobile"))
return "cars";
if (lowerPath.Contains("track") || lowerPath.Contains("circuit") || lowerPath.Contains("course"))
return "tracks";
if (lowerPath.Contains("audio") || lowerPath.Contains("sound") || lowerPath.Contains("music"))
return "audio";
if (lowerPath.Contains("texture") || lowerPath.Contains("material") || lowerPath.Contains("skin"))
return "textures";
if (lowerPath.Contains("ui") || lowerPath.Contains("hud") || lowerPath.Contains("menu"))
return "ui";
if (lowerPath.Contains("event") || lowerPath.Contains("race") || lowerPath.Contains("challenge"))
return "events";
if (lowerPath.Contains("dlc") || lowerPath.Contains("expansion") || lowerPath.Contains("addon"))
return "dlc";
if (lowerPath.Contains("update") || lowerPath.Contains("patch"))
return "updates";
// Fall back to first folder name
var pathParts = fullPath.Split('/', '\\');
if (pathParts.Length > 1)
return pathParts[0].ToLowerInvariant();
return null; // Will default to "base"
}
private string DetermineAssetType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
@@ -467,3 +565,23 @@ public class AssetStats
public long TotalSizeMB { get; set; }
public int TotalDownloads { get; set; }
}
// Manifest models for automatic ZIP metadata detection
public class AssetManifest
{
public string? Version { get; set; }
public string? GameVersion { get; set; }
public string? Description { get; set; }
public string? Author { get; set; }
public string? Category { get; set; }
public List<ManifestAsset>? Assets { get; set; }
}
public class ManifestAsset
{
public string File { get; set; } = string.Empty;
public string? Category { get; set; }
public string? Type { get; set; }
public bool Required { get; set; } = true;
public string? Description { get; set; }
}

View File

@@ -38,6 +38,9 @@ builder.Services.AddScoped<IDrmService, DrmService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<AssetExtractionService>();
// Add HttpClient for URL downloads
builder.Services.AddHttpClient();
// CORS for cross-origin requests
builder.Services.AddCors(options =>
{

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("RR3CommunityServer")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+15e842ce855b748cc97c43261eba408067128330")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f289cdfce9c28a229d8a00547a730c54530d68e9")]
[assembly: System.Reflection.AssemblyProductAttribute("RR3CommunityServer")]
[assembly: System.Reflection.AssemblyTitleAttribute("RR3CommunityServer")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
57cc4aab962a2ad12c842505535b2be3f034220ea319669b999c8dc52168130d
0da7d7611f502a833d49c872526fd4633f406323449f2304a3325dd643f82480

View File

@@ -1 +1 @@
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/15e842ce855b748cc97c43261eba408067128330/*"}}
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/f289cdfce9c28a229d8a00547a730c54530d68e9/*"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"gdYA/PLOQysRMD9wt3+IrqBqQw0g/GZFOcojepf8P6w=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["7Gcs8uTS1W2TjgmuuoBwaL/zy\u002B2wcKht3msEI7xtxEM=","UWedSjPPgrw4tts2Bk2ce0nYJfnBV9zMYOAjYg0PED8=","GecKXPxV0EAagvAtrRNTytwMtFCxZmgKm9sjLyEe8oI=","hf09a5b9bLKSNiG0xcVt876q/fUaHURnZieQui5FrSU=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","bGtvAdvcs6Zz1qOTjdKz5gd/5jOpXDLvMjTZye3i/QI=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","7gMXO5\u002Bhli7od21x4gC/qf3G6ddyyMyoSF6YFX9IaKg=","IdEjAFCVk3xZYjiEMESONot/jkvTj/gnwS5nnpGaIMc=","JVRe\u002Be2d47FunIfxVYRpqRFtljZ8gqrK3xMRy6TCd\u002BQ=","DQG0T8n9f5ohwv9akihU55D4/3WR7\u002BlDnvkdsAHHSgc=","VxDQNRQXYUU41o9SG4HrkKWR59FJIv8lmnwBolB/wE0=","x88k5Bg2fv\u002Bie1eIqFd4doOTQY0lwCNPv/5eJfhIK\u002Bw=","0Slg2/xnc5E9nXprYyph/57wQou\u002BhGSGgKchbo4aNOg="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"gdYA/PLOQysRMD9wt3+IrqBqQw0g/GZFOcojepf8P6w=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["7Gcs8uTS1W2TjgmuuoBwaL/zy\u002B2wcKht3msEI7xtxEM=","UWedSjPPgrw4tts2Bk2ce0nYJfnBV9zMYOAjYg0PED8=","GecKXPxV0EAagvAtrRNTytwMtFCxZmgKm9sjLyEe8oI=","S5l3\u002BBR9dKGtXWyJK2BwVbV5FvygplG8\u002Byh9AE1ZYRQ=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","bGtvAdvcs6Zz1qOTjdKz5gd/5jOpXDLvMjTZye3i/QI=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","7gMXO5\u002Bhli7od21x4gC/qf3G6ddyyMyoSF6YFX9IaKg=","IdEjAFCVk3xZYjiEMESONot/jkvTj/gnwS5nnpGaIMc=","JVRe\u002Be2d47FunIfxVYRpqRFtljZ8gqrK3xMRy6TCd\u002BQ=","DQG0T8n9f5ohwv9akihU55D4/3WR7\u002BlDnvkdsAHHSgc=","VxDQNRQXYUU41o9SG4HrkKWR59FJIv8lmnwBolB/wE0=","x88k5Bg2fv\u002Bie1eIqFd4doOTQY0lwCNPv/5eJfhIK\u002Bw=","0Slg2/xnc5E9nXprYyph/57wQou\u002BhGSGgKchbo4aNOg="],"CachedAssets":{},"CachedCopyCandidates":{}}